diff --git a/app/controllers/api/v1/accounts/integrations/github_controller.rb b/app/controllers/api/v1/accounts/integrations/github_controller.rb new file mode 100644 index 0000000000..8faa8ad091 --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/github_controller.rb @@ -0,0 +1,100 @@ +class Api::V1::Accounts::Integrations::GithubController < Api::V1::Accounts::Integrations::BaseController + include Github::IntegrationHelper + + before_action :check_authorization + before_action :fetch_hook, except: [:create] + + # Completes an install that GitHub redirected through Github::CallbacksController. + def create + return render_connect_error('connection_failed') unless verify_github_state(params[:state]) == Current.account.id + return render_connect_error('installation_not_verified') unless installation_visible_to_user? + + connect_installation + render :update + rescue Integrations::Github::AppClient::Error => e + Rails.logger.error("GitHub connect failed for account #{Current.account.id}: #{e.message}") + render_connect_error('connection_failed') + end + + def repositories + render json: installation_repositories + rescue Integrations::Github::AppClient::AuthorizationError => e + render_access_lost(e) + end + + def update + repository = installation_repositories.find { |name| name.casecmp?(params[:repository].to_s) } + return render json: { error: I18n.t('integration_apps.github.errors.repository_not_granted') }, status: :unprocessable_entity if repository.blank? + + @hook.update!(settings: { 'repository' => repository, 'label' => params[:label].presence }.compact) + rescue Integrations::Github::AppClient::AuthorizationError => e + render_access_lost(e) + end + + # Unbinds the installation from this account only. The app stays installed on + # GitHub, where the organization may still use it for other accounts. + def destroy + @hook.destroy! + head :ok + end + + private + + # A leftover personal-token hook has no installation to list or configure; + # it can only be deleted or replaced by installing the app. + def fetch_hook + hooks = Current.account.hooks.where(app_id: 'github') + hooks = hooks.where.not(reference_id: nil) unless action_name == 'destroy' + @hook = hooks.first! + end + + def installation_id + params[:installation_id].to_s + end + + # `installation_id` arrives through a browser redirect, so anyone can put any id + # there. Binding it unchecked would let one account open issues in another + # organization's repositories. The user who just authorized must be able to see + # the installation on GitHub's side. + def installation_visible_to_user? + return false if params[:code].blank? || installation_id.blank? + + user_token = app_client.exchange_code(params[:code]) + return true if app_client.user_installation_ids(user_token).map(&:to_s).include?(installation_id) + + Rails.logger.warn("GitHub connect for account #{Current.account.id} named installation #{installation_id}, which the authorizing user cannot see") + false + end + + def connect_installation + @hook = Current.account.hooks.find_or_initialize_by(app_id: 'github') + previous = @hook.settings.to_h + settings = { 'label' => previous['label'] } + settings['repository'] = previous['repository'] if repository_still_granted?(previous['repository']) + + @hook.update!(reference_id: installation_id, status: 'enabled', settings: settings.compact_blank) + @hook.reauthorized! + end + + def repository_still_granted?(repository) + repository.present? && app_client.repositories(installation_id).any? { |name| name.casecmp?(repository) } + end + + def installation_repositories + @installation_repositories ||= app_client.repositories(@hook.reference_id) + end + + def app_client + @app_client ||= Integrations::Github::AppClient.new + end + + def render_connect_error(reason) + render json: { error: I18n.t("integration_apps.github.errors.#{reason}"), reason: reason }, status: :unprocessable_entity + end + + def render_access_lost(error) + Rails.logger.warn("GitHub installation #{@hook.reference_id} refused account #{Current.account.id}: #{error.message}") + @hook.prompt_reauthorization! + render json: { error: I18n.t('integration_apps.github.errors.access_lost') }, status: :unprocessable_entity + end +end diff --git a/app/controllers/github/callbacks_controller.rb b/app/controllers/github/callbacks_controller.rb new file mode 100644 index 0000000000..ab89f7f106 --- /dev/null +++ b/app/controllers/github/callbacks_controller.rb @@ -0,0 +1,41 @@ +# GitHub sends the browser here after an install. Nothing is bound yet: the +# settings page posts `code`, `installation_id` and `state` back from inside the +# admin's authenticated session (Integrations::GithubController#create). Binding +# here instead would let an admin of one account hand their `state` to an org +# owner elsewhere and capture that org's installation when the owner installs. +class Github::CallbacksController < ApplicationController + include Github::IntegrationHelper + + def show + account_id = verify_github_state(params[:state]) + return redirect_expired_state if account_id.blank? + + query = if params[:setup_action] == 'request' + # An org member without admin rights only *requested* the install; + # GitHub waits for an org owner, and nothing is installed yet. + { setup_action: 'request' } + else + params.permit(:code, :installation_id, :state).to_h + end + redirect_to settings_url(account_id, query) + end + + private + + # The install itself may already have finished on GitHub, so the admin is + # told to press Connect again rather than dropped on the app root. + def redirect_expired_state + account_id = expired_github_state_account_id(params[:state]) + return redirect_to(frontend_url) if account_id.blank? + + redirect_to settings_url(account_id, { error: 'state_expired' }) + end + + def settings_url(account_id, query) + "#{frontend_url}/app/accounts/#{account_id}/settings/integrations/github?#{query.to_query}" + end + + def frontend_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end +end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 7883e0b712..884fb6228c 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -62,6 +62,8 @@ def allowed_configs 'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET], 'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS], 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], + 'github' => %w[GITHUB_APP_ID GITHUB_APP_SLUG GITHUB_APP_CLIENT_ID GITHUB_APP_CLIENT_SECRET GITHUB_APP_PRIVATE_KEY + GITHUB_APP_WEBHOOK_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET SLACK_SIGNING_SECRET], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION], diff --git a/app/controllers/webhooks/github_controller.rb b/app/controllers/webhooks/github_controller.rb new file mode 100644 index 0000000000..3236006a42 --- /dev/null +++ b/app/controllers/webhooks/github_controller.rb @@ -0,0 +1,55 @@ +class Webhooks::GithubController < ActionController::API + before_action :verify_signature! + + EVENT_HANDLERS = { + 'installation' => :handle_installation, + 'installation_repositories' => :handle_repositories_removed + }.freeze + INSTALLATION_ACTIONS = { + 'deleted' => :prompt_reauthorization!, + 'suspend' => :prompt_reauthorization!, + 'unsuspend' => :reauthorized! + }.freeze + + def events + handler = EVENT_HANDLERS[request.headers['X-GitHub-Event']] + send(handler) if handler + + head :ok + end + + private + + def verify_signature! + secret = GlobalConfigService.load('GITHUB_APP_WEBHOOK_SECRET', nil) + signature = request.headers['X-Hub-Signature-256'] + return head :unauthorized if secret.blank? || signature.blank? + + expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)}" + head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(expected, signature) + end + + def handle_installation + hook_action = INSTALLATION_ACTIONS[payload['action']] + installation_hooks.find_each(&hook_action) if hook_action + end + + def handle_repositories_removed + removed = Array(payload['repositories_removed']).map { |repository| repository['full_name'].downcase } + return if removed.empty? + + installation_hooks.find_each do |hook| + hook.prompt_reauthorization! if removed.include?(hook.settings['repository']&.downcase) + end + end + + def installation_hooks + Integrations::Hook.where(app_id: 'github', reference_id: payload.dig('installation', 'id').to_s) + end + + # Parsed from the raw body because Rails reserves params[:action] for the + # controller action, which would hide the GitHub event's own `action`. + def payload + @payload ||= JSON.parse(request.raw_post) + end +end diff --git a/app/helpers/github/integration_helper.rb b/app/helpers/github/integration_helper.rb new file mode 100644 index 0000000000..6681012c1f --- /dev/null +++ b/app/helpers/github/integration_helper.rb @@ -0,0 +1,42 @@ +# Signs the account id into the `state` of the GitHub App install URL. GitHub +# hands `state` back on the callback, and it is the only thing that tells us +# which account started the install. +module Github::IntegrationHelper + # The state only names the account; binding is gated separately by the + # admin session, so a long TTL costs nothing and survives slow installs. + STATE_TTL = 1.hour + + def generate_github_state(account_id) + secret = github_client_secret + return if secret.blank? + + now = Time.current + JWT.encode({ sub: account_id, iat: now.to_i, exp: (now + STATE_TTL).to_i }, secret, 'HS256') + end + + def verify_github_state(token) + decode_github_state(token, verify_expiration: true) + end + + # Signed by us but past its expiry: good enough to send the admin back to + # their own settings page to retry, never to bind anything. + def expired_github_state_account_id(token) + decode_github_state(token, verify_expiration: false) + end + + private + + def decode_github_state(token, verify_expiration:) + secret = github_client_secret + return if token.blank? || secret.blank? + + JWT.decode(token, secret, true, { algorithm: 'HS256', required_claims: %w[exp], verify_expiration: verify_expiration }).first['sub'] + rescue JWT::DecodeError => e + Rails.logger.warn("Rejected GitHub install state: #{e.message}") + nil + end + + def github_client_secret + GlobalConfigService.load('GITHUB_APP_CLIENT_SECRET', nil) + end +end diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml index 0fb6499486..12824cbbf2 100644 --- a/app/helpers/super_admin/features.yml +++ b/app/helpers/super_admin/features.yml @@ -124,6 +124,12 @@ linear: enabled: true icon: 'icon-linear' config_key: 'linear' +github: + name: 'GitHub' + description: 'Configuration for the GitHub App that opens issues from tickets' + enabled: true + icon: 'icon-github' + config_key: 'github' notion: name: 'Notion' description: 'Configuration for setting up Notion Integration' diff --git a/app/javascript/dashboard/api/integrations/github.js b/app/javascript/dashboard/api/integrations/github.js new file mode 100644 index 0000000000..29c10e56e6 --- /dev/null +++ b/app/javascript/dashboard/api/integrations/github.js @@ -0,0 +1,27 @@ +/* global axios */ + +import ApiClient from '../ApiClient'; + +class GithubAPI extends ApiClient { + constructor() { + super('integrations/github', { accountScoped: true }); + } + + connect({ code, installationId, state }) { + return axios.post(this.url, { + code, + installation_id: installationId, + state, + }); + } + + getRepositories() { + return axios.get(`${this.url}/repositories`); + } + + updateSettings({ repository, label }) { + return axios.patch(this.url, { repository, label }); + } +} + +export default new GithubAPI(); diff --git a/app/javascript/dashboard/api/specs/integrations/github.spec.js b/app/javascript/dashboard/api/specs/integrations/github.spec.js new file mode 100644 index 0000000000..6763c94307 --- /dev/null +++ b/app/javascript/dashboard/api/specs/integrations/github.spec.js @@ -0,0 +1,66 @@ +import GithubAPIClient from '../../integrations/github'; +import ApiClient from '../../ApiClient'; + +describe('#githubAPI', () => { + const originalAxios = window.axios; + const axiosMock = { + get: vi.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + vi.clearAllMocks(); + }); + + it('creates correct instance', () => { + expect(GithubAPIClient).toBeInstanceOf(ApiClient); + }); + + it('completes an installation', () => { + GithubAPIClient.connect({ + code: 'oauth-code', + installationId: '81234567', + state: 'signed-state', + }); + expect(axiosMock.post).toHaveBeenCalledWith('/api/v1/integrations/github', { + code: 'oauth-code', + installation_id: '81234567', + state: 'signed-state', + }); + }); + + it('fetches the repositories of the installation', () => { + GithubAPIClient.getRepositories(); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/github/repositories' + ); + }); + + it('saves the repository and label', () => { + GithubAPIClient.updateSettings({ + repository: 'pathorsAI/inbox', + label: 'support', + }); + expect(axiosMock.patch).toHaveBeenCalledWith( + '/api/v1/integrations/github', + { repository: 'pathorsAI/inbox', label: 'support' } + ); + }); + + it('sends an empty label to clear it', () => { + GithubAPIClient.updateSettings({ + repository: 'pathorsAI/inbox', + label: '', + }); + expect(axiosMock.patch).toHaveBeenCalledWith( + '/api/v1/integrations/github', + { repository: 'pathorsAI/inbox', label: '' } + ); + }); +}); diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index c08931e1da..f3550bc83f 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -119,6 +119,56 @@ }, "LIST_SEPARATOR": ", " }, + "GITHUB": { + "HEADER": "GitHub", + "DELETE": { + "TITLE": "Disconnect GitHub?", + "MESSAGE": "Agents will stop opening GitHub issues from conversations. Issues that were already opened stay on GitHub." + }, + "FORM": { + "TITLE": "Repository for new issues", + "DESCRIPTION": "Issues opened from conversations are created in this repository." + }, + "REPOSITORY": { + "LABEL": "Repository", + "PLACEHOLDER": "Select a repository", + "SEARCH_PLACEHOLDER": "Search repositories", + "LOADING": "Loading repositories...", + "HELP": "Only repositories the GitHub App can access are listed.", + "EMPTY": "The GitHub App can't access any repositories yet. Grant it access to a repository on GitHub, then reload this page.", + "NO_MATCH": "No repositories match your search", + "LOAD_ERROR": "Couldn't load repositories from GitHub. Try again in a moment." + }, + "LABEL": { + "LABEL": "Issue label", + "PLACEHOLDER": "e.g. support", + "HELP": "Optional. Added to every issue opened from a conversation." + }, + "SAVE": "Save", + "CANCEL": "Cancel", + "CHANGE": "Change", + "UPDATE_SUCCESS": "GitHub settings saved", + "UPDATE_ERROR": "Couldn't save the GitHub settings. Try again.", + "CONNECTED_SUCCESS": "GitHub connected", + "CONNECTED": { + "TITLE": "Where issues are opened", + "DESCRIPTION": "Issues opened from conversations are created in this repository.", + "REPOSITORY": "Repository", + "LABEL": "Label", + "NO_LABEL": "None" + }, + "RECONNECT": { + "TITLE": "GitHub needs to be reconnected", + "DESCRIPTION": "The GitHub App was uninstalled, suspended, or lost access to the selected repository. Reconnect to resume opening issues.", + "BUTTON": "Reconnect GitHub" + }, + "AWAITING_APPROVAL": "Waiting for your GitHub organization owner to approve the installation. You'll be able to pick a repository once they approve.", + "ERRORS": { + "INSTALLATION_NOT_VERIFIED": "The GitHub account you signed in with can't see that installation, so it wasn't connected. Install the app with an account that has access to it, or ask an organization owner to connect it.", + "CONNECTION_FAILED": "Something went wrong while connecting GitHub. Try connecting again.", + "STATE_EXPIRED": "The connect link expired before GitHub sent you back. The app may already be installed on GitHub; press Connect again to finish." + } + }, "SHOPIFY": { "HEADER": "Shopify", "DELETE": { diff --git a/app/javascript/dashboard/i18n/locale/zh_TW/integrations.json b/app/javascript/dashboard/i18n/locale/zh_TW/integrations.json index 5b45bbd568..3209d03c94 100644 --- a/app/javascript/dashboard/i18n/locale/zh_TW/integrations.json +++ b/app/javascript/dashboard/i18n/locale/zh_TW/integrations.json @@ -119,6 +119,56 @@ }, "LIST_SEPARATOR": "、" }, + "GITHUB": { + "HEADER": "GitHub", + "DELETE": { + "TITLE": "要解除連接 GitHub 嗎?", + "MESSAGE": "客服人員將無法再從對話建立 GitHub Issue。已經建立的 Issue 會保留在 GitHub 上。" + }, + "FORM": { + "TITLE": "新 Issue 要開在哪個儲存庫", + "DESCRIPTION": "從對話建立的 Issue 會開在這個儲存庫。" + }, + "REPOSITORY": { + "LABEL": "儲存庫", + "PLACEHOLDER": "選擇儲存庫", + "SEARCH_PLACEHOLDER": "搜尋儲存庫", + "LOADING": "正在載入儲存庫...", + "HELP": "只會列出 GitHub App 有權限存取的儲存庫。", + "EMPTY": "GitHub App 目前無法存取任何儲存庫。請先到 GitHub 授權它存取至少一個儲存庫,再重新整理此頁面。", + "NO_MATCH": "找不到符合的儲存庫", + "LOAD_ERROR": "無法從 GitHub 載入儲存庫,請稍後再試。" + }, + "LABEL": { + "LABEL": "Issue 標籤", + "PLACEHOLDER": "例如:support", + "HELP": "選填。從對話建立的每個 Issue 都會加上這個標籤。" + }, + "SAVE": "儲存", + "CANCEL": "取消", + "CHANGE": "變更", + "UPDATE_SUCCESS": "已儲存 GitHub 設定", + "UPDATE_ERROR": "無法儲存 GitHub 設定,請再試一次。", + "CONNECTED_SUCCESS": "已連接 GitHub", + "CONNECTED": { + "TITLE": "Issue 建立位置", + "DESCRIPTION": "從對話建立的 Issue 會開在這個儲存庫。", + "REPOSITORY": "儲存庫", + "LABEL": "標籤", + "NO_LABEL": "無" + }, + "RECONNECT": { + "TITLE": "需要重新連接 GitHub", + "DESCRIPTION": "GitHub App 已被解除安裝、已被暫停,或失去所選儲存庫的存取權限。重新連接後即可繼續建立 Issue。", + "BUTTON": "重新連接 GitHub" + }, + "AWAITING_APPROVAL": "正在等待你的 GitHub 組織擁有者核准安裝。核准後,你就能在這裡選擇儲存庫。", + "ERRORS": { + "INSTALLATION_NOT_VERIFIED": "你登入的 GitHub 帳號看不到這個安裝,因此沒有完成連接。請改用有權限存取它的帳號安裝,或請組織擁有者來連接。", + "CONNECTION_FAILED": "連接 GitHub 時發生問題,請再試一次。", + "STATE_EXPIRED": "連接的連結在回到這裡之前就過期了。App 可能已經裝在 GitHub 上,再按一次「連接」就能完成。" + } + }, "SHOPIFY": { "HEADER": "Shopify", "DELETE": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Github.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Github.vue new file mode 100644 index 0000000000..54092c606c --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Github.vue @@ -0,0 +1,211 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Github/RepositoryForm.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Github/RepositoryForm.vue new file mode 100644 index 0000000000..fc5edf3314 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Github/RepositoryForm.vue @@ -0,0 +1,145 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js index 5087f1eb59..1a45f6c9a4 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js @@ -10,6 +10,7 @@ import Slack from './Slack.vue'; import Linear from './Linear.vue'; import Notion from './Notion.vue'; import Shopify from './Shopify.vue'; +import Github from './Github.vue'; export default { routes: [ @@ -99,6 +100,22 @@ export default { }, props: route => ({ error: route.query.error }), }, + { + path: 'github', + name: 'settings_integrations_github', + component: Github, + meta: { + featureFlag: FEATURE_FLAGS.INTEGRATIONS, + permissions: ['administrator'], + }, + props: route => ({ + setupAction: route.query.setup_action, + error: route.query.error, + code: route.query.code, + installationId: route.query.installation_id, + state: route.query.state, + }), + }, { path: ':integration_id', name: 'settings_applications_integration', diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/specs/Github.spec.js b/app/javascript/dashboard/routes/dashboard/settings/integrations/specs/Github.spec.js new file mode 100644 index 0000000000..8a2fdc7770 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/specs/Github.spec.js @@ -0,0 +1,395 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { createStore } from 'vuex'; +import { withFullI18n } from 'test-i18n'; +import { useAlert } from 'dashboard/composables'; +import Github from '../Github.vue'; +import Integration from '../Integration.vue'; +import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; + +const { connect, getRepositories, updateSettings, routerReplace } = vi.hoisted( + () => ({ + connect: vi.fn(), + getRepositories: vi.fn(), + updateSettings: vi.fn(), + routerReplace: vi.fn(), + }) +); + +vi.mock('dashboard/api/integrations/github', () => ({ + default: { connect, getRepositories, updateSettings }, +})); + +vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() })); + +vi.mock('vue-router', async importOriginal => ({ + ...(await importOriginal()), + useRouter: () => ({ replace: routerReplace }), + useRoute: () => ({ path: '/app/accounts/1/settings/integrations/github' }), +})); + +const i18n = withFullI18n('en'); + +const INSTALL_URL = + 'https://github.com/apps/pathors-inbox/installations/new?state=signed'; + +const installedHook = (overrides = {}) => ({ + id: 5, + app_id: 'github', + status: true, + reference_id: '81234567', + settings: {}, + reauthorization_required: false, + ...overrides, +}); + +const buildStore = hooks => { + const refreshedHooks = { value: null }; + const get = vi.fn(({ commit }) => { + if (refreshedHooks.value) commit('setHooks', refreshedHooks.value); + }); + const store = createStore({ + modules: { + integrations: { + namespaced: true, + state: { + records: [ + { + id: 'github', + name: 'GitHub', + description: 'Open GitHub issues from conversations.', + enabled: Boolean(hooks.length), + action: INSTALL_URL, + hooks, + }, + ], + }, + getters: { + getIntegration: state => id => + state.records.find(record => record.id === id) ?? {}, + }, + mutations: { + setHooks: (state, newHooks) => { + state.records[0].hooks = newHooks; + }, + }, + actions: { get }, + }, + }, + }); + return { store, get, refreshedHooks }; +}; + +const mountPage = async ({ hooks = [], props = {} } = {}) => { + const { store, get, refreshedHooks } = buildStore(hooks); + const wrapper = mount(Github, { + props, + global: { + plugins: [store], + stubs: { + Integration: true, + BaseSettingsHeader: true, + WootLoadingState: true, + }, + }, + }); + await flushPromises(); + return { wrapper, get, refreshedHooks }; +}; + +const integrationCard = wrapper => wrapper.findComponent(Integration); + +describe('Github settings page', () => { + beforeEach(() => { + i18n.global.locale.value = 'en'; + getRepositories.mockResolvedValue({ + data: ['pathorsAI/pathors', 'pathorsAI/inbox'], + }); + updateSettings.mockResolvedValue({ data: {} }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('offers the install link when nothing is connected', async () => { + const { wrapper } = await mountPage(); + + expect(integrationCard(wrapper).props('integrationEnabled')).toBe(false); + expect(integrationCard(wrapper).props('integrationAction')).toBe( + INSTALL_URL + ); + expect(wrapper.text()).not.toContain('Repository for new issues'); + expect(getRepositories).not.toHaveBeenCalled(); + }); + + it('treats a legacy token hook as not connected', async () => { + const { wrapper } = await mountPage({ + hooks: [installedHook({ reference_id: null })], + }); + + expect(integrationCard(wrapper).props('integrationEnabled')).toBe(false); + expect(integrationCard(wrapper).props('integrationAction')).toBe( + INSTALL_URL + ); + }); + + it('asks to reconnect when the installation lost access', async () => { + const { wrapper } = await mountPage({ + hooks: [ + installedHook({ + reauthorization_required: true, + settings: { repository: 'pathorsAI/inbox' }, + }), + ], + }); + + expect(wrapper.text()).toContain('GitHub needs to be reconnected'); + expect(wrapper.text()).toContain( + 'The GitHub App was uninstalled, suspended, or lost access to the selected repository.' + ); + expect(wrapper.find(`a[href="${INSTALL_URL}"]`).exists()).toBe(true); + expect(integrationCard(wrapper).props('integrationEnabled')).toBe(true); + expect(integrationCard(wrapper).props('integrationAction')).toBe( + 'disconnect' + ); + }); + + it('saves the chosen repository and label, then shows them', async () => { + const { wrapper, get, refreshedHooks } = await mountPage({ + hooks: [installedHook()], + }); + + expect(getRepositories).toHaveBeenCalledTimes(1); + expect(wrapper.text()).toContain( + 'Only repositories the GitHub App can access are listed.' + ); + const comboBox = wrapper.findComponent(ComboBox); + expect(comboBox.props('options')).toEqual([ + { value: 'pathorsAI/pathors', label: 'pathorsAI/pathors' }, + { value: 'pathorsAI/inbox', label: 'pathorsAI/inbox' }, + ]); + + comboBox.vm.$emit('update:modelValue', 'pathorsAI/inbox'); + await wrapper.find('input').setValue(' support '); + refreshedHooks.value = [ + installedHook({ + settings: { repository: 'pathorsAI/inbox', label: 'support' }, + }), + ]; + await wrapper + .findAll('button') + .find(button => button.text() === 'Save') + .trigger('click'); + await flushPromises(); + + expect(updateSettings).toHaveBeenCalledWith({ + repository: 'pathorsAI/inbox', + label: 'support', + }); + expect(useAlert).toHaveBeenCalledWith('GitHub settings saved'); + expect(get).toHaveBeenCalledTimes(2); + expect(wrapper.findComponent(ComboBox).exists()).toBe(false); + expect(wrapper.text()).toContain('Where issues are opened'); + expect( + wrapper.find('a[href="https://github.com/pathorsAI/inbox"]').text() + ).toBe('pathorsAI/inbox'); + expect(wrapper.text()).toContain('support'); + }); + + it('shows the server error when the repository is refused', async () => { + updateSettings.mockRejectedValue({ + response: { + status: 422, + data: { error: 'Repository is not part of this installation' }, + }, + }); + const { wrapper } = await mountPage({ hooks: [installedHook()] }); + + wrapper + .findComponent(ComboBox) + .vm.$emit('update:modelValue', 'pathorsAI/pathors'); + await wrapper.vm.$nextTick(); + await wrapper + .findAll('button') + .find(button => button.text() === 'Save') + .trigger('click'); + await flushPromises(); + + expect(wrapper.text()).toContain( + 'Repository is not part of this installation' + ); + expect(useAlert).not.toHaveBeenCalled(); + expect(wrapper.findComponent(ComboBox).exists()).toBe(true); + }); + + it('says so when the installation exposes no repositories', async () => { + getRepositories.mockResolvedValue({ data: [] }); + const { wrapper } = await mountPage({ hooks: [installedHook()] }); + + expect(wrapper.text()).toContain( + "The GitHub App can't access any repositories yet." + ); + }); + + it('refetches the hook when GitHub refuses the repository listing', async () => { + getRepositories.mockRejectedValue({ + response: { status: 422, data: { error: 'Bad credentials' } }, + }); + const { wrapper, get } = await mountPage({ hooks: [installedHook()] }); + + expect(wrapper.text()).toContain("Couldn't load repositories from GitHub."); + expect(get).toHaveBeenCalledTimes(2); + }); + + it('lets the repository be changed from the connected summary', async () => { + const { wrapper } = await mountPage({ + hooks: [ + installedHook({ + settings: { repository: 'pathorsAI/inbox', label: '' }, + }), + ], + }); + + expect(wrapper.text()).toContain('Where issues are opened'); + expect(wrapper.text()).toContain('None'); + expect(getRepositories).not.toHaveBeenCalled(); + + await wrapper + .findAll('button') + .find(button => button.text() === 'Change') + .trigger('click'); + await flushPromises(); + + expect(getRepositories).toHaveBeenCalledTimes(1); + expect(wrapper.findComponent(ComboBox).props('modelValue')).toBe( + 'pathorsAI/inbox' + ); + + await wrapper + .findAll('button') + .find(button => button.text() === 'Cancel') + .trigger('click'); + + expect(wrapper.findComponent(ComboBox).exists()).toBe(false); + expect(wrapper.text()).toContain('Where issues are opened'); + }); + + const INSTALL_QUERY = { + code: 'oauth-code', + installationId: '81234567', + state: 'signed-state', + }; + + it('completes the install from the redirect query before loading', async () => { + let finishConnect; + connect.mockReturnValue( + new Promise(resolve => { + finishConnect = resolve; + }) + ); + const { store, get, refreshedHooks } = buildStore([]); + refreshedHooks.value = [installedHook()]; + const wrapper = mount(Github, { + props: INSTALL_QUERY, + global: { + plugins: [store], + stubs: { + Integration: true, + BaseSettingsHeader: true, + WootLoadingState: true, + }, + }, + }); + await flushPromises(); + + expect(connect).toHaveBeenCalledWith(INSTALL_QUERY); + expect(wrapper.findComponent(Integration).exists()).toBe(false); + expect(routerReplace).not.toHaveBeenCalled(); + expect(get).not.toHaveBeenCalled(); + + finishConnect({ data: installedHook() }); + await flushPromises(); + + expect(useAlert).toHaveBeenCalledWith('GitHub connected'); + expect(routerReplace).toHaveBeenCalledWith( + '/app/accounts/1/settings/integrations/github' + ); + expect(get).toHaveBeenCalledTimes(1); + expect(routerReplace.mock.invocationCallOrder[0]).toBeLessThan( + get.mock.invocationCallOrder[0] + ); + expect(wrapper.findComponent(ComboBox).exists()).toBe(true); + }); + + it.each([ + [ + 'installation_not_verified', + "The GitHub account you signed in with can't see that installation", + ], + ['connection_failed', 'Something went wrong while connecting GitHub.'], + ['something_new', 'Something went wrong while connecting GitHub.'], + ])( + 'shows the notice for a refused install with reason %s', + async (reason, message) => { + connect.mockRejectedValue({ + response: { + status: 422, + data: { error: 'Installation could not be bound', reason }, + }, + }); + const { wrapper, get } = await mountPage({ props: INSTALL_QUERY }); + + expect(wrapper.text()).toContain(message); + expect(useAlert).not.toHaveBeenCalled(); + expect(routerReplace).toHaveBeenCalledWith( + '/app/accounts/1/settings/integrations/github' + ); + expect(get).toHaveBeenCalledTimes(1); + expect(integrationCard(wrapper).props('integrationEnabled')).toBe(false); + } + ); + + it('clears an incomplete install query without connecting', async () => { + await mountPage({ props: { code: 'oauth-code' } }); + + expect(connect).not.toHaveBeenCalled(); + expect(routerReplace).toHaveBeenCalledWith( + '/app/accounts/1/settings/integrations/github' + ); + }); + + it('shows the pending approval notice and clears the query', async () => { + const { wrapper } = await mountPage({ props: { setupAction: 'request' } }); + + expect(wrapper.text()).toContain( + 'Waiting for your GitHub organization owner to approve the installation.' + ); + expect(connect).not.toHaveBeenCalled(); + expect(routerReplace).toHaveBeenCalledWith( + '/app/accounts/1/settings/integrations/github' + ); + }); + + it('leaves the URL alone without an install redirect query', async () => { + await mountPage(); + + expect(connect).not.toHaveBeenCalled(); + expect(routerReplace).not.toHaveBeenCalled(); + }); + + it('renders the zh_TW copy', async () => { + i18n.global.locale.value = 'zh_TW'; + const { wrapper } = await mountPage({ + hooks: [ + installedHook({ + settings: { repository: 'pathorsAI/inbox', label: 'support' }, + }), + ], + props: { setupAction: 'request' }, + }); + + expect(wrapper.text()).toContain('Issue 建立位置'); + expect(wrapper.text()).toContain('儲存庫'); + expect(wrapper.text()).toContain('正在等待你的 GitHub 組織擁有者核准安裝'); + }); +}); diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb index e5b716b248..cd56c26578 100644 --- a/app/models/integrations/app.rb +++ b/app/models/integrations/app.rb @@ -1,5 +1,6 @@ class Integrations::App include Linear::IntegrationHelper + include Github::IntegrationHelper include Pathors::IntegrationHelper # Pathors: never offered in the catalog. Each of these asks the customer to @@ -15,6 +16,10 @@ class Integrations::App # The presence of such a bot is what "connected to Pathors" means. PATHORS_CALLBACK_URL_FRAGMENT = '/integration/chatwoot/callback'.freeze + GITHUB_APP_CONFIG_KEYS = %w[ + GITHUB_APP_ID GITHUB_APP_SLUG GITHUB_APP_CLIENT_ID GITHUB_APP_CLIENT_SECRET GITHUB_APP_PRIVATE_KEY GITHUB_APP_WEBHOOK_SECRET + ].freeze + attr_accessor :params def initialize(params) @@ -62,6 +67,8 @@ def action "#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}" when 'linear' build_linear_action + when 'github' + build_github_action when 'pathors' build_pathors_action when 'shopify' @@ -81,12 +88,14 @@ def active?(account) # Whether the instance/account has what this app needs to be usable at all — # an OAuth client on the instance, a feature flag on the account, or both. - def credentials_available?(account) + def credentials_available?(account) # rubocop:disable Metrics/CyclomaticComplexity case params[:id] when 'slack' GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present? when 'linear' account.feature_enabled?('linear_integration') && GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present? + when 'github' + GITHUB_APP_CONFIG_KEYS.all? { |key| GlobalConfigService.load(key, nil).present? } when 'shopify' shopify_enabled?(account) when 'leadsquared' @@ -113,6 +122,11 @@ def build_linear_action ].join('&') end + def build_github_action + slug = GlobalConfigService.load('GITHUB_APP_SLUG', nil) + "https://github.com/apps/#{slug}/installations/new?state=#{generate_github_state(Current.account.id)}" + end + def enabled?(account) case params[:id] when 'webhook' diff --git a/app/policies/hook_policy.rb b/app/policies/hook_policy.rb index 153a967989..5244b94a2b 100644 --- a/app/policies/hook_policy.rb +++ b/app/policies/hook_policy.rb @@ -15,6 +15,11 @@ def destroy? @account_user.administrator? end + # GitHub App repository picker (Integrations::GithubController) + def repositories? + @account_user.administrator? + end + # CRM sync log and backfill (Integrations::CrmSyncController) def sync_events? @account_user.administrator? diff --git a/app/views/api/v1/accounts/integrations/github/update.json.jbuilder b/app/views/api/v1/accounts/integrations/github/update.json.jbuilder new file mode 100644 index 0000000000..6c6f810010 --- /dev/null +++ b/app/views/api/v1/accounts/integrations/github/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/hook', formats: [:json], resource: @hook diff --git a/app/views/api/v1/models/_hook.json.jbuilder b/app/views/api/v1/models/_hook.json.jbuilder index 3b9b14029e..d902059d84 100644 --- a/app/views/api/v1/models/_hook.json.jbuilder +++ b/app/views/api/v1/models/_hook.json.jbuilder @@ -4,6 +4,7 @@ json.status resource.enabled? json.inbox resource.inbox&.slice(:id, :name) json.account_id resource.account_id json.hook_type resource.hook_type +json.reauthorization_required resource.reauthorization_required? if Current.account_user&.administrator? visible_properties = resource.app&.visible_properties || [] diff --git a/app/views/super_admin/application/_icons.html.erb b/app/views/super_admin/application/_icons.html.erb index 39253c9695..1e98faee29 100644 --- a/app/views/super_admin/application/_icons.html.erb +++ b/app/views/super_admin/application/_icons.html.erb @@ -170,6 +170,10 @@ + + + + diff --git a/config/installation_config.yml b/config/installation_config.yml index 319402166a..826d235607 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -413,6 +413,42 @@ description: 'Linear client secret' type: secret ## ------ End of Configs added for Linear ------ ## +## ------ Configs added for the GitHub App ------ ## +- name: GITHUB_APP_ID + display_title: 'GitHub App ID' + value: + locked: false + description: 'Numeric App ID from the GitHub App settings page' +- name: GITHUB_APP_SLUG + display_title: 'GitHub App Slug' + value: + locked: false + description: 'URL name of the GitHub App, as in github.com/apps/' +- name: GITHUB_APP_CLIENT_ID + display_title: 'GitHub App Client ID' + value: + locked: false + description: 'Client ID of the GitHub App' +- name: GITHUB_APP_CLIENT_SECRET + display_title: 'GitHub App Client Secret' + value: + locked: false + description: 'Client secret of the GitHub App' + type: secret +# `code` rather than `secret`: the PEM is multi-line and a password input drops its newlines. +- name: GITHUB_APP_PRIVATE_KEY + display_title: 'GitHub App Private Key' + value: + locked: false + description: 'Private key (PEM) generated on the GitHub App settings page' + type: code +- name: GITHUB_APP_WEBHOOK_SECRET + display_title: 'GitHub App Webhook Secret' + value: + locked: false + description: 'Webhook secret set on the GitHub App; verifies the installation events GitHub sends' + type: secret +## ------ End of Configs added for the GitHub App ------ ## ## ------ Configs added for Notion ------ ## - name: NOTION_CLIENT_ID diff --git a/config/integration/apps.yml b/config/integration/apps.yml index e01e34ea0e..642dc61fc9 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -373,46 +373,21 @@ github: id: github logo: github.png i18n_key: github - action: /github hook_type: account allow_multiple_hooks: false + # The installation id lives in reference_id; GitHub tokens are minted per request and never stored. settings_json_schema: { 'type': 'object', 'properties': { - 'access_token': { 'type': 'string' }, 'repository': { 'type': 'string', 'pattern': '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' }, 'label': { 'type': 'string' }, }, - 'required': ['access_token', 'repository'], + 'required': [], 'additionalProperties': false, } - settings_form_schema: - [ - { - 'label': 'Personal Access Token', - 'type': 'password', - 'name': 'access_token', - 'validation': 'required', - 'help': 'A GitHub personal access token that is allowed to create issues in the repository below.', - }, - { - 'label': 'Repository', - 'type': 'text', - 'name': 'repository', - 'validation': 'required', - 'help': 'The repository issues are created in, in owner/repository format.', - }, - { - 'label': 'Label', - 'type': 'text', - 'name': 'label', - 'validation': '', - 'help': 'Optional. Applied to every issue this integration creates.', - }, - ] visible_properties: ['repository', 'label'] # Twenty CRM (fork). Links contacts to Twenty people, keeps both sides' blank diff --git a/config/locales/en.yml b/config/locales/en.yml index 3d347408a8..d571b41942 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -502,8 +502,13 @@ en: github: name: 'GitHub' short_description: 'Open a GitHub issue whenever a ticket is created.' - description: 'Hand cases over to engineering automatically. Whenever a ticket is created, this integration opens an issue in the GitHub repository you configure, carrying the contact, the ticket details and a link back to the conversation.' + description: 'Hand cases over to engineering automatically. Connect the Pathors Inbox GitHub App and pick a repository; whenever a ticket is created, the app opens an issue there carrying the contact, the ticket details and a link back to the conversation.' issue_created_note: 'GitHub issue created for this ticket: %{url}' + errors: + repository_not_granted: 'The GitHub App is not installed on that repository.' + access_lost: 'GitHub no longer accepts this installation. Reconnect GitHub to continue.' + installation_not_verified: 'Your GitHub account cannot see that installation, so it was not connected.' + connection_failed: 'Connecting GitHub failed. Try again.' issue: contact: 'Contact' ticket_type: 'Ticket type' diff --git a/config/locales/zh_TW.yml b/config/locales/zh_TW.yml index aadd529b88..c347431775 100644 --- a/config/locales/zh_TW.yml +++ b/config/locales/zh_TW.yml @@ -468,8 +468,13 @@ zh_TW: github: name: 'GitHub' short_description: '建立工單時自動開一則 GitHub issue。' - description: '自動把案件交接給工程團隊。每當有人建立工單,此整合就會在您設定的 GitHub 儲存庫開一則 issue,並帶上聯絡人、工單資訊與回到對話的連結。' + description: '自動把案件交接給工程團隊。連接 Pathors Inbox GitHub App 並選好儲存庫後,每當有人建立工單,App 就會在該儲存庫開一則 issue,並帶上聯絡人、工單資訊與回到對話的連結。' issue_created_note: '已為這張工單建立 GitHub issue:%{url}' + errors: + repository_not_granted: 'GitHub App 沒有安裝在這個儲存庫上。' + access_lost: 'GitHub 已不再接受這個安裝,請重新連接 GitHub。' + installation_not_verified: '你的 GitHub 帳號看不到這個安裝,因此沒有連接。' + connection_failed: '連接 GitHub 失敗,請再試一次。' issue: contact: '聯絡人' ticket_type: '工單類型' diff --git a/config/routes.rb b/config/routes.rb index 512ed825f8..b880cba1b5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -453,6 +453,11 @@ get :linked_issues end end + resource :github, controller: 'github', only: [:create, :update, :destroy] do + collection do + get :repositories + end + end resource :notion, controller: 'notion', only: [] do collection do delete :destroy @@ -730,6 +735,7 @@ post 'webhooks/instagram', to: 'webhooks/instagram#events' post 'webhooks/tiktok', to: 'webhooks/tiktok#events' post 'webhooks/shopify', to: 'webhooks/shopify#events' + post 'webhooks/github', to: 'webhooks/github#events' namespace :twitter do resource :callback, only: [:show] @@ -739,6 +745,10 @@ resource :callback, only: [:show] end + namespace :github do + resource :callback, only: [:show] + end + namespace :pathors do resource :callback, only: [:show] end diff --git a/lib/integrations/github/app_client.rb b/lib/integrations/github/app_client.rb new file mode 100644 index 0000000000..0bddef9bbf --- /dev/null +++ b/lib/integrations/github/app_client.rb @@ -0,0 +1,91 @@ +# Talks to GitHub as the "Pathors Inbox" GitHub App. Nothing long-lived is stored: +# the app's private key signs a short JWT, which buys an installation token that +# expires within the hour, so an uninstall on GitHub cuts us off immediately. +class Integrations::Github::AppClient + API_URL = 'https://api.github.com'.freeze + OAUTH_TOKEN_URL = 'https://github.com/login/oauth/access_token'.freeze + API_VERSION = '2022-11-28'.freeze + PER_PAGE = 100 + TOKEN_REFRESH_MARGIN = 5.minutes + # 422 is how the token endpoint says the requested repository is no longer + # granted to the installation; every one of these needs the admin to reconnect. + REAUTHORIZATION_STATUSES = [401, 403, 404, 422].freeze + + class Error < StandardError; end + class AuthorizationError < Error; end + + def installation_token(installation_id, repository: nil) + cache_key = ['github_app_installation_token', installation_id, repository&.downcase].compact.join(':') + cached = Rails.cache.read(cache_key) + return cached if cached.present? + + body = { repositories: [repository.split('/').last], permissions: { issues: 'write' } } if repository.present? + response = HTTParty.post("#{API_URL}/app/installations/#{installation_id}/access_tokens", + headers: headers(app_jwt), body: body&.to_json) + ensure_success!(response) + + expires_in = Time.zone.parse(response['expires_at']) - TOKEN_REFRESH_MARGIN - Time.current + Rails.cache.write(cache_key, response['token'], expires_in: expires_in) if expires_in.positive? + response['token'] + end + + def repositories(installation_id) + paginate("#{API_URL}/installation/repositories", installation_token(installation_id), 'repositories') + .pluck('full_name') + end + + def exchange_code(code) + response = HTTParty.post(OAUTH_TOKEN_URL, + headers: { 'Accept' => 'application/json' }, + body: { client_id: config('GITHUB_APP_CLIENT_ID'), client_secret: config('GITHUB_APP_CLIENT_SECRET'), code: code }) + ensure_success!(response) + # GitHub answers a bad or expired code with 200 and an `error` field. + raise Error, "GitHub code exchange failed: #{response['error'] || response.code}" if response['access_token'].blank? + + response['access_token'] + end + + def user_installation_ids(user_token) + paginate("#{API_URL}/user/installations", user_token, 'installations').pluck('id') + end + + private + + def paginate(url, token, key) + results = [] + next_url = "#{url}?per_page=#{PER_PAGE}" + while next_url + response = HTTParty.get(next_url, headers: headers(token)) + ensure_success!(response) + results.concat(response[key]) + next_url = response.headers['link']&.match(/<([^>]+)>;\s*rel="next"/)&.captures&.first + end + results + end + + def ensure_success!(response) + return if response.success? + + error_class = REAUTHORIZATION_STATUSES.include?(response.code) ? AuthorizationError : Error + raise error_class, "GitHub request to #{response.request.last_uri} failed: #{response.code} #{response.body}" + end + + def app_jwt + now = Time.current.to_i + JWT.encode({ iss: config('GITHUB_APP_ID'), iat: now - 60, exp: now + 9.minutes.to_i }, + OpenSSL::PKey::RSA.new(config('GITHUB_APP_PRIVATE_KEY')), 'RS256') + end + + def headers(token) + { + 'Authorization' => "Bearer #{token}", + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => API_VERSION, + 'Content-Type' => 'application/json' + } + end + + def config(key) + GlobalConfigService.load(key, nil) + end +end diff --git a/lib/integrations/github/processor_service.rb b/lib/integrations/github/processor_service.rb index 89c5734911..0300b6add6 100644 --- a/lib/integrations/github/processor_service.rb +++ b/lib/integrations/github/processor_service.rb @@ -7,25 +7,37 @@ class Integrations::Github::ProcessorService include ::Rails.application.routes.url_helpers ISSUES_URL = 'https://api.github.com/repos/%s/issues'.freeze - API_VERSION = '2022-11-28'.freeze DESCRIPTION_LIMIT = 500 pattr_initialize [:hook!, :event_name!, :event_data!] def perform - return unless event_name == 'ticket.created' - # The event can reach us more than once (job re-enqueue, replayed dispatch); - # a case that already has an issue must not get a second one. - return if conversation.additional_attributes['github_issue'].present? + return unless issue_wanted? response = create_issue + return hook.prompt_reauthorization! if access_lost?(response) return log_failure(response) unless response.success? record_issue(response.parsed_response) + rescue Integrations::Github::AppClient::AuthorizationError => e + Rails.logger.warn("GitHub installation #{hook.reference_id} refused hook #{hook.id}: #{e.message}") + hook.prompt_reauthorization! end private + def issue_wanted? + return false unless event_name == 'ticket.created' + # Not installed through the app yet (a hook left over from the old token + # setup), no repository picked, or GitHub already told us the installation + # is gone: nothing to do until an admin finishes the setup. + return false if hook.reference_id.blank? || settings[:repository].blank? || hook.reauthorization_required? + + # The event can reach us more than once (job re-enqueue, replayed dispatch); + # a case that already has an issue must not get a second one. + conversation.additional_attributes['github_issue'].blank? + end + def ticket event_data[:ticket] end @@ -42,15 +54,19 @@ def create_issue HTTParty.post( format(ISSUES_URL, repository: settings[:repository]), headers: { - 'Authorization' => "Bearer #{settings[:access_token]}", + 'Authorization' => "Bearer #{installation_token}", 'Accept' => 'application/vnd.github+json', - 'X-GitHub-Api-Version' => API_VERSION, + 'X-GitHub-Api-Version' => Integrations::Github::AppClient::API_VERSION, 'Content-Type' => 'application/json' }, body: issue_payload.to_json ) end + def installation_token + Integrations::Github::AppClient.new.installation_token(hook.reference_id, repository: settings[:repository]) + end + def issue_payload payload = { title: ticket.subject, body: issue_body } payload[:labels] = [settings[:label]] if settings[:label].present? @@ -101,6 +117,14 @@ def record_issue(issue) ) end + # Uninstalled, suspended, or the repository left the installation. A 403 that + # carries rate-limit headers is throttling, which passes on its own. + def access_lost?(response) + return true if [401, 404].include?(response.code) + + response.code == 403 && response.headers['retry-after'].blank? && response.headers['x-ratelimit-remaining'] != '0' + end + def log_failure(response) Rails.logger.error("GitHub issue creation failed for hook #{hook.id} on #{settings[:repository]}: #{response.code} #{response.body}") end diff --git a/spec/controllers/api/v1/accounts/integrations/github_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/github_controller_spec.rb new file mode 100644 index 0000000000..b45929d129 --- /dev/null +++ b/spec/controllers/api/v1/accounts/integrations/github_controller_spec.rb @@ -0,0 +1,184 @@ +require 'rails_helper' + +RSpec.describe 'GitHub Integration API', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let!(:hook) { create(:integrations_hook, :github, account: account, settings: {}) } + let(:base_url) { "/api/v1/accounts/#{account.id}/integrations/github" } + let(:json_headers) { { 'Content-Type' => 'application/json' } } + let(:repositories_url) { 'https://api.github.com/installation/repositories?per_page=100' } + + before do + allow(GlobalConfigService).to receive(:load).and_call_original + allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_ID', nil).and_return('123456') + allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_PRIVATE_KEY', nil).and_return(OpenSSL::PKey::RSA.generate(2048).to_pem) + stub_request(:post, 'https://api.github.com/app/installations/4242/access_tokens') + .to_return(status: 201, body: { token: 'ghs_token', expires_at: 1.hour.from_now.iso8601 }.to_json, headers: json_headers) + stub_request(:get, repositories_url) + .to_return(status: 200, body: { repositories: [{ full_name: 'pathorsAI/pathors' }, { full_name: 'pathorsAI/inbox' }] }.to_json, + headers: json_headers) + end + + describe 'POST /api/v1/accounts/:account_id/integrations/github' do + let(:client_secret) { 'github-client-secret' } + let(:state) { JWT.encode({ sub: account.id, iat: Time.current.to_i, exp: 10.minutes.from_now.to_i }, client_secret, 'HS256') } + let(:visible_installations) { [{ id: 4242 }] } + let(:connect_params) { { code: 'oauth-code', installation_id: '4242', state: state } } + + before do + hook.destroy! + allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_CLIENT_ID', nil).and_return('Iv1.client') + allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_CLIENT_SECRET', nil).and_return(client_secret) + stub_request(:post, 'https://github.com/login/oauth/access_token') + .to_return(status: 200, body: { access_token: 'ghu_user_token' }.to_json, headers: json_headers) + stub_request(:get, 'https://api.github.com/user/installations?per_page=100') + .with(headers: { 'Authorization' => 'Bearer ghu_user_token' }) + .to_return(status: 200, body: { installations: visible_installations }.to_json, headers: json_headers) + end + + it 'binds an installation the authorizing user can see' do + post base_url, params: connect_params, headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:ok) + connected = account.hooks.find_by!(app_id: 'github') + expect(connected.reference_id).to eq('4242') + expect(connected.settings).to eq({}) + expect(response.parsed_body['reference_id']).to eq('4242') + end + + it 'refuses a forged installation id and writes nothing' do + post base_url, params: connect_params.merge(installation_id: '9999'), headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['reason']).to eq('installation_not_verified') + expect(account.hooks.where(app_id: 'github')).to be_empty + end + + it 'refuses a state minted for another account' do + other_state = JWT.encode({ sub: create(:account).id, exp: 10.minutes.from_now.to_i }, client_secret, 'HS256') + + post base_url, params: connect_params.merge(state: other_state), headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['reason']).to eq('connection_failed') + expect(account.hooks.where(app_id: 'github')).to be_empty + expect(WebMock).not_to have_requested(:post, 'https://github.com/login/oauth/access_token') + end + + it 'keeps the label and a repository the installation still grants when reconnecting' do + previous = create(:integrations_hook, :github, account: account, reference_id: '1', + settings: { 'repository' => 'pathorsai/inbox', 'label' => 'support' }) + previous.prompt_reauthorization! + + post base_url, params: connect_params, headers: admin.create_new_auth_token, as: :json + + previous.reload + expect(previous.reference_id).to eq('4242') + expect(previous.settings).to eq('repository' => 'pathorsai/inbox', 'label' => 'support') + expect(previous.reauthorization_required?).to be(false) + end + + it 'drops a repository the new installation does not grant' do + create(:integrations_hook, :github, account: account, reference_id: '1', settings: { 'repository' => 'someone/else', 'label' => 'support' }) + + post base_url, params: connect_params, headers: admin.create_new_auth_token, as: :json + + expect(account.hooks.find_by!(app_id: 'github').settings).to eq('label' => 'support') + end + + it 'reports a code GitHub rejects' do + stub_request(:post, 'https://github.com/login/oauth/access_token') + .to_return(status: 200, body: { error: 'bad_verification_code' }.to_json, headers: json_headers) + + post base_url, params: connect_params, headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['reason']).to eq('connection_failed') + end + + it 'is limited to administrators' do + post base_url, params: connect_params, headers: agent.create_new_auth_token, as: :json + + expect(response).to have_http_status(:unauthorized) + expect(account.hooks.where(app_id: 'github')).to be_empty + end + end + + describe 'GET /api/v1/accounts/:account_id/integrations/github/repositories' do + it 'lists the repositories the installation grants' do + get "#{base_url}/repositories", headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:ok) + expect(response.parsed_body).to eq(%w[pathorsAI/pathors pathorsAI/inbox]) + end + + it 'is limited to administrators' do + get "#{base_url}/repositories", headers: agent.create_new_auth_token, as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'asks for a reconnect when GitHub no longer accepts the installation' do + stub_request(:post, 'https://api.github.com/app/installations/4242/access_tokens').to_return(status: 404, body: '{}', headers: json_headers) + + get "#{base_url}/repositories", headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(hook.reauthorization_required?).to be(true) + end + + it 'treats a leftover personal-token hook as not connected' do + hook.update!(reference_id: nil) + + get "#{base_url}/repositories", headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:not_found) + expect(hook.reauthorization_required?).to be(false) + expect(WebMock).not_to have_requested(:any, /github\.com/) + end + end + + describe 'PATCH /api/v1/accounts/:account_id/integrations/github' do + it 'saves a granted repository and the label' do + patch base_url, params: { repository: 'pathorsai/inbox', label: 'support' }, headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:ok) + expect(hook.reload.settings).to eq('repository' => 'pathorsAI/inbox', 'label' => 'support') + expect(response.parsed_body['settings']).to eq('repository' => 'pathorsAI/inbox', 'label' => 'support') + end + + it 'refuses a repository outside the installation' do + patch base_url, params: { repository: 'someone/else' }, headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(hook.reload.settings).to eq({}) + end + + it 'is limited to administrators' do + patch base_url, params: { repository: 'pathorsAI/inbox' }, headers: agent.create_new_auth_token, as: :json + + expect(response).to have_http_status(:unauthorized) + expect(hook.reload.settings).to eq({}) + end + end + + describe 'DELETE /api/v1/accounts/:account_id/integrations/github' do + it 'removes the hook without calling GitHub' do + delete base_url, headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:ok) + expect(account.hooks.where(app_id: 'github')).to be_empty + expect(WebMock).not_to have_requested(:any, /github\.com/) + end + + it 'removes a leftover personal-token hook' do + hook.update!(reference_id: nil) + + delete base_url, headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:ok) + expect(account.hooks.where(app_id: 'github')).to be_empty + end + end +end diff --git a/spec/controllers/github/callbacks_controller_spec.rb b/spec/controllers/github/callbacks_controller_spec.rb new file mode 100644 index 0000000000..56143fa5e8 --- /dev/null +++ b/spec/controllers/github/callbacks_controller_spec.rb @@ -0,0 +1,57 @@ +require 'rails_helper' + +RSpec.describe Github::CallbacksController, type: :request do + let(:account) { create(:account) } + let(:client_secret) { 'github-client-secret' } + let(:state) { JWT.encode({ sub: account.id, iat: Time.current.to_i, exp: 10.minutes.from_now.to_i }, client_secret, 'HS256') } + let(:settings_url) { "http://www.example.com/app/accounts/#{account.id}/settings/integrations/github" } + + around do |example| + with_modified_env(FRONTEND_URL: 'http://www.example.com') { example.run } + end + + before do + allow(GlobalConfigService).to receive(:load).and_call_original + allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_CLIENT_SECRET', nil).and_return(client_secret) + end + + describe 'GET /github/callback' do + it 'hands the install to the settings page of the account named in the state, without binding it' do + get github_callback_path, params: { code: 'oauth-code', installation_id: '4242', setup_action: 'install', state: state } + + expect(response).to redirect_to("#{settings_url}?#{{ code: 'oauth-code', installation_id: '4242', state: state }.to_query}") + expect(Integrations::Hook.where(app_id: 'github')).to be_empty + expect(WebMock).not_to have_requested(:any, /github\.com/) + end + + it 'sends a pending org approval back to the settings page' do + get github_callback_path, params: { setup_action: 'request', state: state } + + expect(response).to redirect_to("#{settings_url}?setup_action=request") + end + + it 'redirects to the app root without naming any account when the state is forged' do + forged = JWT.encode({ sub: account.id, exp: 10.minutes.from_now.to_i }, 'not-the-secret', 'HS256') + + get github_callback_path, params: { code: 'oauth-code', installation_id: '4242', state: forged } + + expect(response).to redirect_to('http://www.example.com') + end + + it 'sends an expired state back to its account to connect again, without the install' do + expired = JWT.encode({ sub: account.id, exp: 1.minute.ago.to_i }, client_secret, 'HS256') + + get github_callback_path, params: { code: 'oauth-code', installation_id: '4242', state: expired } + + expect(response).to redirect_to("#{settings_url}?error=state_expired") + end + + it 'rejects a state without an expiry' do + eternal = JWT.encode({ sub: account.id }, client_secret, 'HS256') + + get github_callback_path, params: { code: 'oauth-code', installation_id: '4242', state: eternal } + + expect(response).to redirect_to('http://www.example.com') + end + end +end diff --git a/spec/controllers/webhooks/github_controller_spec.rb b/spec/controllers/webhooks/github_controller_spec.rb new file mode 100644 index 0000000000..309ab05f0e --- /dev/null +++ b/spec/controllers/webhooks/github_controller_spec.rb @@ -0,0 +1,93 @@ +require 'rails_helper' + +RSpec.describe Webhooks::GithubController, type: :request do + let(:account) { create(:account) } + let!(:hook) { create(:integrations_hook, :github, account: account, reference_id: '4242') } + let(:webhook_secret) { 'github-webhook-secret' } + let(:event) { 'installation' } + let(:payload) { { action: 'deleted', installation: { id: 4242 } } } + let(:body) { payload.to_json } + let(:signature) { "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', webhook_secret, body)}" } + let(:headers) { { 'CONTENT_TYPE' => 'application/json', 'X-GitHub-Event' => event, 'X-Hub-Signature-256' => signature } } + + before do + allow(GlobalConfigService).to receive(:load).and_call_original + allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_WEBHOOK_SECRET', nil).and_return(webhook_secret) + end + + it 'rejects a payload signed with another secret' do + post '/webhooks/github', params: body, + headers: headers.merge('X-Hub-Signature-256' => "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', 'wrong', body)}") + + expect(response).to have_http_status(:unauthorized) + expect(hook.reauthorization_required?).to be(false) + end + + it 'rejects every payload when no webhook secret is configured' do + allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_WEBHOOK_SECRET', nil).and_return(nil) + + post '/webhooks/github', params: body, headers: headers + + expect(response).to have_http_status(:unauthorized) + end + + it 'asks for a reconnect when the app is uninstalled, and stays that way on replay' do + 2.times { post '/webhooks/github', params: body, headers: headers } + + expect(response).to have_http_status(:ok) + expect(hook.reauthorization_required?).to be(true) + end + + it 'leaves hooks of other installations alone' do + other = create(:integrations_hook, :github, account: create(:account), reference_id: '7') + + post '/webhooks/github', params: body, headers: headers + + expect(other.reauthorization_required?).to be(false) + end + + context 'when a suspended installation is unsuspended' do + let(:payload) { { action: 'unsuspend', installation: { id: 4242 } } } + + it 'clears the reconnect prompt' do + hook.prompt_reauthorization! + + post '/webhooks/github', params: body, headers: headers + + expect(hook.reauthorization_required?).to be(false) + end + end + + context 'when repositories are removed from the installation' do + let(:event) { 'installation_repositories' } + let(:removed) { [{ full_name: 'PathorsAI/Chatwoot' }] } + let(:payload) { { action: 'removed', installation: { id: 4242 }, repositories_removed: removed } } + + it 'asks for a reconnect when the selected repository is among them' do + post '/webhooks/github', params: body, headers: headers + + expect(hook.reauthorization_required?).to be(true) + end + + context 'when the selected repository is still granted' do + let(:removed) { [{ full_name: 'pathorsAI/other' }] } + + it 'keeps the hook working' do + post '/webhooks/github', params: body, headers: headers + + expect(hook.reauthorization_required?).to be(false) + end + end + end + + context 'with an event the integration does not handle' do + let(:event) { 'push' } + + it 'acknowledges it' do + post '/webhooks/github', params: body, headers: headers + + expect(response).to have_http_status(:ok) + expect(hook.reauthorization_required?).to be(false) + end + end +end diff --git a/spec/factories/integrations/hooks.rb b/spec/factories/integrations/hooks.rb index 9ba7aeaa04..c2f9a4c7a3 100644 --- a/spec/factories/integrations/hooks.rb +++ b/spec/factories/integrations/hooks.rb @@ -47,7 +47,8 @@ trait :github do app_id { 'github' } - settings { { 'access_token' => 'github_pat_token', 'repository' => 'pathorsAI/chatwoot' } } + reference_id { '4242' } + settings { { 'repository' => 'pathorsAI/chatwoot' } } end trait :shopify do diff --git a/spec/lib/integrations/github/app_client_spec.rb b/spec/lib/integrations/github/app_client_spec.rb new file mode 100644 index 0000000000..9b624fd1df --- /dev/null +++ b/spec/lib/integrations/github/app_client_spec.rb @@ -0,0 +1,108 @@ +require 'rails_helper' + +describe Integrations::Github::AppClient do + subject(:client) { described_class.new } + + let(:private_key) { OpenSSL::PKey::RSA.generate(2048) } + let(:tokens_url) { 'https://api.github.com/app/installations/4242/access_tokens' } + let(:token_response) { { token: 'ghs_installation_token', expires_at: 1.hour.from_now.iso8601 } } + let(:json_headers) { { 'Content-Type' => 'application/json' } } + + before do + allow(GlobalConfigService).to receive(:load).and_call_original + { + 'GITHUB_APP_ID' => '123456', + 'GITHUB_APP_CLIENT_ID' => 'Iv1.client', + 'GITHUB_APP_CLIENT_SECRET' => 'client-secret', + 'GITHUB_APP_PRIVATE_KEY' => private_key.to_pem + }.each { |key, value| allow(GlobalConfigService).to receive(:load).with(key, nil).and_return(value) } + end + + describe '#installation_token' do + it 'signs an app JWT with the private key and returns the installation token' do + stub_request(:post, tokens_url).to_return(status: 201, body: token_response.to_json, headers: json_headers) + + expect(client.installation_token(4242)).to eq('ghs_installation_token') + + expect(WebMock).to(have_requested(:post, tokens_url).with do |request| + jwt = request.headers['Authorization'].delete_prefix('Bearer ') + payload, header = JWT.decode(jwt, private_key.public_key, true, algorithm: 'RS256') + header['alg'] == 'RS256' && payload['iss'] == '123456' && request.body.blank? + end) + end + + it 'scopes the token to the repository and to issues when a repository is given' do + stub_request(:post, tokens_url).to_return(status: 201, body: token_response.to_json, headers: json_headers) + + client.installation_token(4242, repository: 'pathorsAI/pathors') + + expect(WebMock).to have_requested(:post, tokens_url) + .with(body: { repositories: ['pathors'], permissions: { issues: 'write' } }.to_json) + end + + it 'reuses a cached token until five minutes before it expires' do + allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new) + stub_request(:post, tokens_url).to_return(status: 201, body: token_response.to_json, headers: json_headers) + + freeze_time do + 2.times { client.installation_token(4242) } + expect(WebMock).to have_requested(:post, tokens_url).once + + travel 54.minutes + client.installation_token(4242) + expect(WebMock).to have_requested(:post, tokens_url).once + + travel 2.minutes + client.installation_token(4242) + expect(WebMock).to have_requested(:post, tokens_url).twice + end + end + + it 'raises an authorization error when the installation is gone' do + stub_request(:post, tokens_url).to_return(status: 404, body: { message: 'Not Found' }.to_json, headers: json_headers) + + expect { client.installation_token(4242) }.to raise_error(described_class::AuthorizationError, /404/) + end + end + + describe '#repositories' do + it 'follows pagination and returns every full name' do + stub_request(:post, tokens_url).to_return(status: 201, body: token_response.to_json, headers: json_headers) + stub_request(:get, 'https://api.github.com/installation/repositories?per_page=100') + .with(headers: { 'Authorization' => 'Bearer ghs_installation_token' }) + .to_return(status: 200, body: { repositories: [{ full_name: 'pathorsAI/pathors' }] }.to_json, + headers: json_headers.merge('Link' => '; rel="next"')) + stub_request(:get, 'https://api.github.com/installation/repositories?per_page=100&page=2') + .to_return(status: 200, body: { repositories: [{ full_name: 'pathorsAI/inbox' }] }.to_json, headers: json_headers) + + expect(client.repositories(4242)).to eq(%w[pathorsAI/pathors pathorsAI/inbox]) + end + end + + describe '#exchange_code' do + it 'returns the user token' do + stub_request(:post, 'https://github.com/login/oauth/access_token') + .with(body: { client_id: 'Iv1.client', client_secret: 'client-secret', code: 'oauth-code' }) + .to_return(status: 200, body: { access_token: 'ghu_user_token' }.to_json, headers: json_headers) + + expect(client.exchange_code('oauth-code')).to eq('ghu_user_token') + end + + it 'raises when GitHub rejects the code' do + stub_request(:post, 'https://github.com/login/oauth/access_token') + .to_return(status: 200, body: { error: 'bad_verification_code' }.to_json, headers: json_headers) + + expect { client.exchange_code('stale') }.to raise_error(described_class::Error, /bad_verification_code/) + end + end + + describe '#user_installation_ids' do + it 'lists the installations the user can see' do + stub_request(:get, 'https://api.github.com/user/installations?per_page=100') + .with(headers: { 'Authorization' => 'Bearer ghu_user_token' }) + .to_return(status: 200, body: { installations: [{ id: 4242 }, { id: 7 }] }.to_json, headers: json_headers) + + expect(client.user_installation_ids('ghu_user_token')).to eq([4242, 7]) + end + end +end diff --git a/spec/lib/integrations/github/processor_service_spec.rb b/spec/lib/integrations/github/processor_service_spec.rb index 4f4680e11a..f5aa279739 100644 --- a/spec/lib/integrations/github/processor_service_spec.rb +++ b/spec/lib/integrations/github/processor_service_spec.rb @@ -9,10 +9,20 @@ let(:hook) { create(:integrations_hook, :github, account: account) } let(:ticket) { create(:ticket, conversation: conversation, subject: 'Refund never arrived', ticket_type: 'issue') } let(:issues_url) { 'https://api.github.com/repos/pathorsAI/chatwoot/issues' } + let(:tokens_url) { 'https://api.github.com/app/installations/4242/access_tokens' } let(:issue_response) do { 'html_url' => 'https://github.com/pathorsAI/chatwoot/issues/42', 'number' => 42 } end + before do + allow(GlobalConfigService).to receive(:load).and_call_original + allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_ID', nil).and_return('123456') + allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_PRIVATE_KEY', nil).and_return(OpenSSL::PKey::RSA.generate(2048).to_pem) + stub_request(:post, tokens_url) + .to_return(status: 201, body: { token: 'ghs_installation_token', expires_at: 1.hour.from_now.iso8601 }.to_json, + headers: { 'Content-Type' => 'application/json' }) + end + describe '#perform' do context 'when a ticket is created' do before do @@ -27,7 +37,7 @@ described_class.new(hook: hook, event_name: 'ticket.created', event_data: { ticket: ticket }).perform expect(WebMock).to have_requested(:post, issues_url) - .with(headers: { 'Authorization' => 'Bearer github_pat_token', 'X-GitHub-Api-Version' => '2022-11-28' }) { |request| + .with(headers: { 'Authorization' => 'Bearer ghs_installation_token', 'X-GitHub-Api-Version' => '2022-11-28' }) { |request| body = JSON.parse(request.body) expect(body['title']).to eq('Refund never arrived') expect(body['body']).to include('Ada Lovelace ') @@ -84,19 +94,87 @@ end end - context 'when github rejects the request' do - it 'logs the failure without raising or recording an issue' do + context 'when no repository has been picked yet' do + it 'does not call github at all' do + hook.update!(settings: {}) + + described_class.new(hook: hook, event_name: 'ticket.created', event_data: { ticket: ticket }).perform + + expect(WebMock).not_to have_requested(:post, tokens_url) + expect(WebMock).not_to have_requested(:post, issues_url) + end + end + + context 'when the hook predates the GitHub App' do + it 'does not call github at all' do + hook.update!(reference_id: nil) + + described_class.new(hook: hook, event_name: 'ticket.created', event_data: { ticket: ticket }).perform + + expect(WebMock).not_to have_requested(:post, tokens_url) + end + end + + context 'when the hook is waiting for a reconnect' do + it 'does not call github at all' do + hook.prompt_reauthorization! + + described_class.new(hook: hook, event_name: 'ticket.created', event_data: { ticket: ticket }).perform + + expect(WebMock).not_to have_requested(:post, tokens_url) + end + end + + context 'when the app no longer reaches the repository' do + it 'asks for a reconnect without raising or recording an issue' do stub_request(:post, issues_url).to_return(status: 404, body: { message: 'Not Found' }.to_json, headers: { 'Content-Type' => 'application/json' }) - allow(Rails.logger).to receive(:error) expect do described_class.new(hook: hook, event_name: 'ticket.created', event_data: { ticket: ticket }).perform end.not_to raise_error - expect(Rails.logger).to have_received(:error).with(/404.*Not Found/) + expect(hook.reauthorization_required?).to be(true) expect(conversation.reload.additional_attributes['github_issue']).to be_nil end end + + context 'when github throttles the request' do + it 'keeps the hook connected' do + stub_request(:post, issues_url).to_return(status: 403, body: { message: 'secondary rate limit' }.to_json, + headers: { 'Content-Type' => 'application/json', 'Retry-After' => '60' }) + + described_class.new(hook: hook, event_name: 'ticket.created', event_data: { ticket: ticket }).perform + + expect(hook.reauthorization_required?).to be(false) + end + end + + context 'when the installation is gone' do + it 'asks for a reconnect without raising' do + stub_request(:post, tokens_url).to_return(status: 404, body: { message: 'Not Found' }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + expect do + described_class.new(hook: hook, event_name: 'ticket.created', event_data: { ticket: ticket }).perform + end.not_to raise_error + + expect(hook.reauthorization_required?).to be(true) + expect(WebMock).not_to have_requested(:post, issues_url) + end + end + + context 'when github rejects the issue itself' do + it 'logs the failure and keeps the hook connected' do + stub_request(:post, issues_url).to_return(status: 422, body: { message: 'Validation Failed' }.to_json, + headers: { 'Content-Type' => 'application/json' }) + allow(Rails.logger).to receive(:error) + + described_class.new(hook: hook, event_name: 'ticket.created', event_data: { ticket: ticket }).perform + + expect(Rails.logger).to have_received(:error).with(/422.*Validation Failed/) + expect(hook.reauthorization_required?).to be(false) + end + end end end diff --git a/spec/models/integrations/app_spec.rb b/spec/models/integrations/app_spec.rb index c121a467b6..5232772ea4 100644 --- a/spec/models/integrations/app_spec.rb +++ b/spec/models/integrations/app_spec.rb @@ -60,6 +60,24 @@ end end + context 'when the app is github' do + let(:app_name) { 'github' } + + it 'points at the app install page with a state that names the account and expires' do + allow(GlobalConfigService).to receive(:load).and_call_original + allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_SLUG', nil).and_return('pathors-inbox') + allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_CLIENT_SECRET', nil).and_return('github-client-secret') + + uri = URI.parse(app.action) + state = CGI.parse(uri.query)['state'].first + payload = JWT.decode(state, 'github-client-secret', true, algorithm: 'HS256').first + + expect("#{uri.scheme}://#{uri.host}#{uri.path}").to eq('https://github.com/apps/pathors-inbox/installations/new') + expect(payload['sub']).to eq(account.id) + expect(payload['exp'] - payload['iat']).to eq(1.hour.to_i) + end + end + context 'when the app is pathors' do let(:app_name) { 'pathors' } let(:connect_secret) { 'test_connect_secret' } @@ -196,6 +214,41 @@ end end + context 'when the app is github' do + let(:app_name) { 'github' } + let(:github_config) do + { + 'GITHUB_APP_ID' => '123456', + 'GITHUB_APP_SLUG' => 'pathors-inbox', + 'GITHUB_APP_CLIENT_ID' => 'Iv1.client', + 'GITHUB_APP_CLIENT_SECRET' => 'secret', + 'GITHUB_APP_PRIVATE_KEY' => 'pem', + 'GITHUB_APP_WEBHOOK_SECRET' => 'hook-secret' + } + end + + before do + allow(GlobalConfigService).to receive(:load).and_call_original + github_config.each { |key, value| allow(GlobalConfigService).to receive(:load).with(key, nil).and_return(value) } + end + + it 'returns true when every GitHub App credential is configured' do + expect(app.active?(account)).to be true + end + + it 'returns false when the private key is missing' do + allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_PRIVATE_KEY', nil).and_return(nil) + + expect(app.active?(account)).to be false + end + + it 'returns false when the webhook secret is missing, since uninstalls would go unnoticed' do + allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_WEBHOOK_SECRET', nil).and_return(nil) + + expect(app.active?(account)).to be false + end + end + context 'when the app is pathors' do let(:app_name) { 'pathors' }