Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
692f5f6375 | ||
|
|
b1c2db5435 | ||
|
|
92a1fb8ab7 | ||
|
|
e055cead35 |
+1
-1
@@ -582,7 +582,7 @@ GEM
|
||||
uri (>= 0.11.1)
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.24)
|
||||
net-imap (0.6.4.1)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
class Api::V1::Profile::SessionsController < Api::BaseController
|
||||
before_action :set_session, only: [:destroy]
|
||||
|
||||
def index
|
||||
@sessions = current_user.user_sessions.where(client_id: active_token_client_ids).order(last_activity_at: :desc)
|
||||
@current_client_id = request.headers['client']
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @session.current?(request.headers['client'])
|
||||
render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
revoke_token!(@session.client_id)
|
||||
@session.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_session
|
||||
@session = current_user.user_sessions.find(params[:id])
|
||||
end
|
||||
|
||||
def revoke_token!(client_id)
|
||||
tokens = current_user.tokens
|
||||
tokens.delete(client_id)
|
||||
current_user.update!(tokens: tokens)
|
||||
end
|
||||
|
||||
def active_token_client_ids
|
||||
now = Time.current.to_i
|
||||
(current_user.tokens || {}).select { |_, v| v['expiry'].to_i > now }.keys
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base
|
||||
include RequestExceptionHandler
|
||||
include Pundit::Authorization
|
||||
include SwitchLocale
|
||||
include TrackSessionActivity
|
||||
|
||||
skip_before_action :verify_authenticity_token
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
module TrackSessionActivity
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_action :update_session_activity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_session_activity
|
||||
return unless current_user
|
||||
return if request.headers['client'].blank?
|
||||
|
||||
UserSessionTrackingService.new(
|
||||
user: current_user,
|
||||
request: request,
|
||||
client_id: request.headers['client']
|
||||
).update_activity!
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Session activity update failed: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -20,6 +20,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
end
|
||||
|
||||
def render_create_success
|
||||
track_user_session
|
||||
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
|
||||
end
|
||||
|
||||
@@ -114,6 +115,19 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
def render_mfa_error(message_key, status = :bad_request)
|
||||
render json: { error: I18n.t(message_key) }, status: status
|
||||
end
|
||||
|
||||
def track_user_session
|
||||
client_id = @token&.try(:client) || response.headers['client']
|
||||
return unless client_id.present? && @resource.present?
|
||||
|
||||
UserSessionTrackingService.new(
|
||||
user: @resource,
|
||||
request: request,
|
||||
client_id: client_id
|
||||
).create_or_update!
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Session tracking failed: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
|
||||
|
||||
@@ -106,4 +106,10 @@ export default {
|
||||
const urlData = endPoints('resetAccessToken');
|
||||
return axios.post(urlData.url);
|
||||
},
|
||||
getSessions() {
|
||||
return axios.get('/api/v1/profile/sessions');
|
||||
},
|
||||
revokeSession(id) {
|
||||
return axios.delete(`/api/v1/profile/sessions/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -154,6 +154,10 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
|
||||
SHARE_CLICKED: 'Year in Review: Share clicked',
|
||||
});
|
||||
|
||||
export const SESSION_EVENTS = Object.freeze({
|
||||
REVOKED_FROM_PROFILE: 'Revoked an active session',
|
||||
});
|
||||
|
||||
export const ONBOARDING_EVENTS = Object.freeze({
|
||||
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
|
||||
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
|
||||
|
||||
@@ -86,6 +86,17 @@
|
||||
"NOTE": "Manage additional security features for your account.",
|
||||
"MFA_BUTTON": "Manage Two-Factor Authentication"
|
||||
},
|
||||
"SESSIONS_SECTION": {
|
||||
"TITLE": "Active Sessions",
|
||||
"NOTE": "These are the devices currently logged in to your account.",
|
||||
"CURRENT": "Current session",
|
||||
"REVOKE": "Revoke",
|
||||
"REVOKE_SUCCESS": "Session revoked successfully",
|
||||
"REVOKE_ERROR": "Unable to revoke session. Please try again.",
|
||||
"FETCH_ERROR": "Unable to fetch sessions. Please try again.",
|
||||
"LAST_ACTIVE": "Last active",
|
||||
"UNKNOWN_DEVICE": "Unknown device"
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access Token",
|
||||
"NOTE": "This token can be used if you are building an API based integration",
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatDistanceToNow, parseISO } from 'date-fns';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import authAPI from 'dashboard/api/auth';
|
||||
import AnalyticsHelper from 'dashboard/helper/AnalyticsHelper';
|
||||
import { SESSION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const sessions = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const relativeTime = dateStr => {
|
||||
if (!dateStr) return '';
|
||||
return formatDistanceToNow(parseISO(dateStr), { addSuffix: true });
|
||||
};
|
||||
|
||||
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
|
||||
|
||||
const deviceIcon = session => {
|
||||
const name = (session.device_name || '').toLowerCase();
|
||||
if (
|
||||
name.includes('iphone') ||
|
||||
name.includes('android') ||
|
||||
name.includes('mobile')
|
||||
) {
|
||||
return 'i-lucide-smartphone';
|
||||
}
|
||||
if (name.includes('ipad') || name.includes('tablet')) {
|
||||
return 'i-lucide-tablet';
|
||||
}
|
||||
return 'i-lucide-monitor';
|
||||
};
|
||||
|
||||
const sessionLabel = session => {
|
||||
const parts = [];
|
||||
if (!isUnknown(session.browser_name)) {
|
||||
parts.push(
|
||||
session.browser_version
|
||||
? `${session.browser_name} ${session.browser_version}`
|
||||
: session.browser_name
|
||||
);
|
||||
}
|
||||
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
|
||||
return (
|
||||
parts.join(' on ') ||
|
||||
t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.UNKNOWN_DEVICE')
|
||||
);
|
||||
};
|
||||
|
||||
const locationLabel = session => {
|
||||
const parts = [];
|
||||
if (session.city) parts.push(session.city);
|
||||
if (session.country) parts.push(session.country);
|
||||
return parts.join(', ');
|
||||
};
|
||||
|
||||
const fetchSessions = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await authAPI.getSessions();
|
||||
sessions.value = data;
|
||||
} catch {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.FETCH_ERROR'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const revokeSession = async session => {
|
||||
try {
|
||||
await authAPI.revokeSession(session.id);
|
||||
sessions.value = sessions.value.filter(s => s.id !== session.id);
|
||||
AnalyticsHelper.track(SESSION_EVENTS.REVOKED_FROM_PROFILE);
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchSessions);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background p-4"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon
|
||||
:icon="deviceIcon(session)"
|
||||
class="size-5 mt-0.5 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ sessionLabel(session) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="session.current"
|
||||
class="rounded-full bg-n-teal-3 px-2 py-0.5 text-caption text-n-teal-11"
|
||||
>
|
||||
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.CURRENT') }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="locationLabel(session)"
|
||||
class="text-body-b3 text-n-slate-11"
|
||||
>
|
||||
{{ locationLabel(session) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="session.last_activity_at"
|
||||
class="text-body-b3 text-n-slate-10"
|
||||
>
|
||||
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.LAST_ACTIVE') }}
|
||||
{{ relativeTime(session.last_activity_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!session.current"
|
||||
type="button"
|
||||
faded
|
||||
xs
|
||||
:label="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE')"
|
||||
color="ruby"
|
||||
@click="revokeSession(session)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -20,6 +20,7 @@ import SectionLayout from '../account/components/SectionLayout.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import AccessToken from './AccessToken.vue';
|
||||
import MfaSettingsCard from './MfaSettingsCard.vue';
|
||||
import ActiveSessions from './ActiveSessions.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ export default {
|
||||
AudioNotifications,
|
||||
AccessToken,
|
||||
MfaSettingsCard,
|
||||
ActiveSessions,
|
||||
BaseSettingsHeader,
|
||||
},
|
||||
setup() {
|
||||
@@ -307,6 +309,13 @@ export default {
|
||||
>
|
||||
<MfaSettingsCard />
|
||||
</SectionLayout>
|
||||
<SectionLayout
|
||||
with-border
|
||||
:title="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.TITLE')"
|
||||
:description="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.NOTE')"
|
||||
>
|
||||
<ActiveSessions />
|
||||
</SectionLayout>
|
||||
<Policy :permissions="audioNotificationPermissions">
|
||||
<SectionLayout
|
||||
with-border
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
class UserSessionIpLookupJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(session)
|
||||
return if session.ip_address.blank?
|
||||
|
||||
result = IpLookupService.new.perform(session.ip_address)
|
||||
return unless result
|
||||
|
||||
session.update_columns( # rubocop:disable Rails/SkipsModelValidations
|
||||
city: result.city,
|
||||
country: result.country,
|
||||
country_code: result.country_code
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "UserSessionIpLookupJob failed: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -69,8 +69,10 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
# Enables voice: turns calling on at Meta (idempotent), subscribes the `calls`
|
||||
# webhook field, and sets calling_enabled. Raises on Meta failure.
|
||||
# Enables voice: turns calling on at Meta (idempotent), then re-registers webhooks
|
||||
# with the in-memory calling_enabled flag so the `calls` field is subscribed. The
|
||||
# flag is persisted only after registration succeeds, so a webhook failure can't
|
||||
# leave the inbox reporting voice_enabled? while the WABA isn't subscribed to calls.
|
||||
# Saved with validate: false to skip validate_provider_config's remote credential
|
||||
# re-check, which could spuriously fail and desync the flag from Meta.
|
||||
def enable_voice_calling!
|
||||
@@ -78,21 +80,21 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
raise 'WhatsApp calling requires the channel_voice feature' unless account.feature_enabled?('channel_voice')
|
||||
|
||||
provider_service.update_calling_status('ENABLED')
|
||||
webhook_setup_service.register_callback
|
||||
self.provider_config = provider_config.merge('calling_enabled' => true)
|
||||
webhook_setup_service.register_callback
|
||||
save!(validate: false)
|
||||
end
|
||||
|
||||
# Disables voice: unsets calling_enabled (gates the call subsystem) and drops
|
||||
# `calls` from the webhook subscription (best-effort, so a Meta outage can't
|
||||
# trap admins). Leaves Meta's WABA calling.status untouched.
|
||||
# Disables voice: unsets calling_enabled (gates the call subsystem) and re-registers
|
||||
# webhooks, which drops `calls` from the subscription (best-effort, so a Meta outage
|
||||
# can't trap admins). Leaves Meta's WABA calling.status untouched.
|
||||
def disable_voice_calling!
|
||||
raise 'WhatsApp calling requires a whatsapp_cloud inbox' unless voice_calling_supported?
|
||||
|
||||
self.provider_config = provider_config.merge('calling_enabled' => false)
|
||||
save!(validate: false)
|
||||
begin
|
||||
webhook_setup_service.register_callback(subscribed_fields: %w[messages smb_message_echoes])
|
||||
webhook_setup_service.register_callback
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[WHATSAPP CALL] disable webhook re-subscribe failed: #{e.message}"
|
||||
end
|
||||
|
||||
@@ -101,6 +101,7 @@ class User < ApplicationRecord
|
||||
has_many :messages, as: :sender, dependent: :nullify
|
||||
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify
|
||||
|
||||
has_many :user_sessions, dependent: :destroy
|
||||
has_many :custom_filters, dependent: :destroy_async
|
||||
has_many :dashboard_apps, dependent: :nullify
|
||||
has_many :mentions, dependent: :destroy_async
|
||||
@@ -118,6 +119,7 @@ class User < ApplicationRecord
|
||||
|
||||
before_validation :set_password_and_uid, on: :create
|
||||
after_destroy :remove_macros
|
||||
after_save :sync_user_sessions, if: :saved_change_to_tokens?
|
||||
|
||||
scope :order_by_full_name, -> { order('lower(name) ASC') }
|
||||
|
||||
@@ -214,6 +216,11 @@ class User < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def sync_user_sessions
|
||||
active_client_ids = (tokens || {}).keys
|
||||
user_sessions.where.not(client_id: active_client_ids).destroy_all
|
||||
end
|
||||
|
||||
def remove_macros
|
||||
macros.personal.destroy_all
|
||||
end
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: user_sessions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# browser_name :string
|
||||
# browser_version :string
|
||||
# city :string
|
||||
# country :string
|
||||
# country_code :string
|
||||
# device_name :string
|
||||
# ip_address :string
|
||||
# last_activity_at :datetime
|
||||
# platform_name :string
|
||||
# platform_version :string
|
||||
# user_agent :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# client_id :string not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_user_sessions_on_user_id (user_id)
|
||||
# index_user_sessions_on_user_id_and_client_id (user_id,client_id) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (user_id => users.id)
|
||||
#
|
||||
|
||||
class UserSession < ApplicationRecord
|
||||
ACTIVITY_THROTTLE = 5.minutes
|
||||
|
||||
belongs_to :user
|
||||
|
||||
validates :client_id, presence: true, uniqueness: { scope: :user_id }
|
||||
|
||||
def current?(active_client_id)
|
||||
client_id == active_client_id
|
||||
end
|
||||
|
||||
def should_update_activity?
|
||||
last_activity_at.nil? || last_activity_at < ACTIVITY_THROTTLE.ago
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
class UserSessionTrackingService
|
||||
def initialize(user:, request:, client_id:)
|
||||
@user = user
|
||||
@request = request
|
||||
@client_id = client_id
|
||||
end
|
||||
|
||||
def create_or_update!
|
||||
session = @user.user_sessions.find_or_initialize_by(client_id: @client_id)
|
||||
session.assign_attributes(session_attributes)
|
||||
session.last_activity_at = Time.current
|
||||
session.save!
|
||||
UserSessionIpLookupJob.perform_later(session) if session.ip_address.present?
|
||||
session
|
||||
end
|
||||
|
||||
def update_activity!
|
||||
session = @user.user_sessions.find_by(client_id: @client_id)
|
||||
return unless session&.should_update_activity?
|
||||
|
||||
session.update_columns(last_activity_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def session_attributes
|
||||
browser = Browser.new(@request.user_agent)
|
||||
|
||||
{
|
||||
ip_address: @request.remote_ip,
|
||||
user_agent: @request.user_agent,
|
||||
browser_name: browser.name,
|
||||
browser_version: browser.full_version,
|
||||
device_name: browser.device.name,
|
||||
platform_name: browser.platform.name,
|
||||
platform_version: browser.platform.version
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -60,7 +60,7 @@ class Whatsapp::FacebookApiClient
|
||||
data['code_verification_status'] == 'VERIFIED'
|
||||
end
|
||||
|
||||
WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes calls].freeze
|
||||
WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes].freeze
|
||||
|
||||
def subscribe_waba_webhook(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS)
|
||||
# Step 1: Subscribe app to WABA first (required before override)
|
||||
|
||||
@@ -17,9 +17,9 @@ class Whatsapp::WebhookSetupService
|
||||
setup_webhook
|
||||
end
|
||||
|
||||
def register_callback(subscribed_fields: nil)
|
||||
def register_callback
|
||||
validate_parameters!
|
||||
setup_webhook(subscribed_fields: subscribed_fields)
|
||||
setup_webhook
|
||||
end
|
||||
|
||||
private
|
||||
@@ -55,21 +55,23 @@ class Whatsapp::WebhookSetupService
|
||||
@channel.save!
|
||||
end
|
||||
|
||||
def setup_webhook(subscribed_fields: nil)
|
||||
def setup_webhook
|
||||
callback_url = build_callback_url
|
||||
verify_token = @channel.provider_config['webhook_verify_token']
|
||||
|
||||
args = [@waba_id, callback_url, verify_token]
|
||||
if subscribed_fields
|
||||
@api_client.subscribe_waba_webhook(*args, subscribed_fields: subscribed_fields)
|
||||
else
|
||||
@api_client.subscribe_waba_webhook(*args)
|
||||
end
|
||||
@api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token, subscribed_fields: subscribed_fields)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
|
||||
raise "Webhook setup failed: #{e.message}"
|
||||
end
|
||||
|
||||
# Subscribe to `calls` only when voice calling is enabled on the inbox
|
||||
def subscribed_fields
|
||||
fields = %w[messages smb_message_echoes]
|
||||
fields << 'calls' if @channel.provider_config['calling_enabled']
|
||||
fields
|
||||
end
|
||||
|
||||
def build_callback_url
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
phone_number = @channel.phone_number
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
json.array! @sessions do |session|
|
||||
json.id session.id
|
||||
json.browser_name session.browser_name
|
||||
json.browser_version session.browser_version
|
||||
json.device_name session.device_name
|
||||
json.platform_name session.platform_name
|
||||
json.platform_version session.platform_version
|
||||
json.ip_address session.ip_address
|
||||
json.city session.city
|
||||
json.country session.country
|
||||
json.country_code session.country_code
|
||||
json.last_activity_at session.last_activity_at
|
||||
json.created_at session.created_at
|
||||
json.current session.current?(@current_client_id)
|
||||
end
|
||||
@@ -47,6 +47,10 @@ en:
|
||||
saml_not_available: SAML authentication is not available in this installation.
|
||||
inbox_deletetion_response: Your inbox deletion request will be processed in some time.
|
||||
|
||||
profile_settings:
|
||||
sessions:
|
||||
cannot_revoke_current: You cannot revoke the current session.
|
||||
|
||||
errors:
|
||||
account:
|
||||
reporting_timezone:
|
||||
|
||||
@@ -437,6 +437,7 @@ Rails.application.routes.draw do
|
||||
post :verify
|
||||
post :backup_codes
|
||||
end
|
||||
resources :sessions, only: [:index, :destroy]
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
class CreateUserSessions < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :user_sessions do |t|
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.string :client_id, null: false
|
||||
t.string :ip_address
|
||||
t.string :user_agent
|
||||
t.string :browser_name
|
||||
t.string :browser_version
|
||||
t.string :device_name
|
||||
t.string :platform_name
|
||||
t.string :platform_version
|
||||
t.string :city
|
||||
t.string :country
|
||||
t.string :country_code
|
||||
t.datetime :last_activity_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :user_sessions, [:user_id, :client_id], unique: true
|
||||
end
|
||||
end
|
||||
+22
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -1253,6 +1253,26 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) do
|
||||
t.index ["name", "account_id"], name: "index_teams_on_name_and_account_id", unique: true
|
||||
end
|
||||
|
||||
create_table "user_sessions", force: :cascade do |t|
|
||||
t.bigint "user_id", null: false
|
||||
t.string "client_id", null: false
|
||||
t.string "ip_address"
|
||||
t.string "user_agent"
|
||||
t.string "browser_name"
|
||||
t.string "browser_version"
|
||||
t.string "device_name"
|
||||
t.string "platform_name"
|
||||
t.string "platform_version"
|
||||
t.string "city"
|
||||
t.string "country"
|
||||
t.string "country_code"
|
||||
t.datetime "last_activity_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["user_id", "client_id"], name: "index_user_sessions_on_user_id_and_client_id", unique: true
|
||||
t.index ["user_id"], name: "index_user_sessions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "users", id: :serial, force: :cascade do |t|
|
||||
t.string "provider", default: "email", null: false
|
||||
t.string "uid", default: "", null: false
|
||||
@@ -1325,6 +1345,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) do
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "inboxes", "portals"
|
||||
add_foreign_key "user_sessions", "users"
|
||||
create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1).
|
||||
on("accounts").
|
||||
after(:insert).
|
||||
|
||||
@@ -163,4 +163,21 @@ RSpec.describe DeviseOverrides::SessionsController, type: :controller do
|
||||
expect(response).to redirect_to('/frontend/app/login?error=access-denied')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'session tracking' do
|
||||
let(:user) { create(:user, password: 'Test@123456') }
|
||||
let(:browser_ua) { 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15' }
|
||||
|
||||
context 'with a successful login' do
|
||||
before { request.env['HTTP_USER_AGENT'] = browser_ua }
|
||||
|
||||
it 'creates a UserSession row for the new client_id' do
|
||||
expect { post :create, params: { email: user.email, password: 'Test@123456' } }.to change(user.user_sessions, :count).by(1)
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.browser_name).to eq('Safari')
|
||||
expect(session.platform_name).to eq('macOS')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,10 @@ RSpec.describe Onboarding::HelpCenterArticleBuilder do
|
||||
|
||||
expect(Firecrawl::Configuration).not_to receive(:client)
|
||||
expect { builder.perform }
|
||||
.to raise_error(Onboarding::HelpCenterErrors::ArticleBuildFailed, /no source urls/)
|
||||
.to raise_error(StandardError) { |error|
|
||||
expect(error.class.name).to eq('Onboarding::HelpCenterErrors::ArticleBuildFailed')
|
||||
expect(error.message).to include('no source urls')
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe UserSessionIpLookupJob do
|
||||
let(:user) { create(:user) }
|
||||
let(:session) { user.user_sessions.create!(client_id: 'c', ip_address: '8.8.8.8', last_activity_at: Time.current) }
|
||||
let(:geo_result) { OpenStruct.new(city: 'Mountain View', country: 'United States', country_code: 'US') }
|
||||
let(:ip_lookup) { instance_double(IpLookupService) }
|
||||
|
||||
before { allow(IpLookupService).to receive(:new).and_return(ip_lookup) }
|
||||
|
||||
it 'backfills geo data on the session' do
|
||||
allow(ip_lookup).to receive(:perform).with('8.8.8.8').and_return(geo_result)
|
||||
|
||||
described_class.perform_now(session)
|
||||
|
||||
session.reload
|
||||
expect(session.city).to eq('Mountain View')
|
||||
expect(session.country).to eq('United States')
|
||||
expect(session.country_code).to eq('US')
|
||||
end
|
||||
|
||||
it 'is a no-op when ip_address is blank' do
|
||||
session.update_columns(ip_address: nil) # rubocop:disable Rails/SkipsModelValidations
|
||||
|
||||
described_class.perform_now(session)
|
||||
|
||||
expect(IpLookupService).not_to have_received(:new)
|
||||
end
|
||||
|
||||
it 'leaves the session untouched when lookup returns nil' do
|
||||
allow(ip_lookup).to receive(:perform).and_return(nil)
|
||||
|
||||
described_class.perform_now(session)
|
||||
|
||||
session.reload
|
||||
expect(session.city).to be_nil
|
||||
expect(session.country).to be_nil
|
||||
end
|
||||
|
||||
it 'swallows lookup errors so a flaky geocoder does not poison the queue' do
|
||||
allow(ip_lookup).to receive(:perform).and_raise(StandardError.new('boom'))
|
||||
|
||||
expect { described_class.perform_now(session) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe UserSession do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:user) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
subject { described_class.new(user: user, client_id: 'abc') }
|
||||
|
||||
it { is_expected.to validate_presence_of(:client_id) }
|
||||
|
||||
it 'validates uniqueness of client_id scoped to user_id' do
|
||||
described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current)
|
||||
|
||||
duplicate = described_class.new(user: user, client_id: 'abc')
|
||||
expect(duplicate).not_to be_valid
|
||||
expect(duplicate.errors[:client_id]).to be_present
|
||||
end
|
||||
|
||||
it 'allows the same client_id for different users' do
|
||||
other = create(:user)
|
||||
described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current)
|
||||
|
||||
expect(described_class.new(user: other, client_id: 'abc', last_activity_at: Time.current)).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe '#current?' do
|
||||
let(:session) { described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current) }
|
||||
|
||||
it 'returns true when client_id matches' do
|
||||
expect(session.current?('abc')).to be true
|
||||
end
|
||||
|
||||
it 'returns false when client_id differs' do
|
||||
expect(session.current?('xyz')).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#should_update_activity?' do
|
||||
let(:session) { described_class.new(user: user, client_id: 'abc') }
|
||||
|
||||
it 'returns true when last_activity_at is nil' do
|
||||
session.last_activity_at = nil
|
||||
expect(session.should_update_activity?).to be true
|
||||
end
|
||||
|
||||
it 'returns true when last_activity_at is older than the throttle window' do
|
||||
session.last_activity_at = 10.minutes.ago
|
||||
expect(session.should_update_activity?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when last_activity_at is within the throttle window' do
|
||||
session.last_activity_at = 1.minute.ago
|
||||
expect(session.should_update_activity?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -254,4 +254,37 @@ RSpec.describe User do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sync_user_sessions callback' do
|
||||
let(:user_with_tokens) do
|
||||
u = create(:user)
|
||||
u.tokens = {
|
||||
'client-a' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i },
|
||||
'client-b' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i }
|
||||
}
|
||||
u.save!
|
||||
u.user_sessions.create!(client_id: 'client-a', last_activity_at: Time.current)
|
||||
u.user_sessions.create!(client_id: 'client-b', last_activity_at: Time.current)
|
||||
u
|
||||
end
|
||||
|
||||
it 'destroys user_sessions whose client_id is no longer in tokens' do
|
||||
user_with_tokens.tokens = user_with_tokens.tokens.except('client-a')
|
||||
|
||||
expect { user_with_tokens.save! }.to change(user_with_tokens.user_sessions, :count).by(-1)
|
||||
expect(user_with_tokens.user_sessions.pluck(:client_id)).to eq(['client-b'])
|
||||
end
|
||||
|
||||
it 'leaves user_sessions alone when tokens did not change' do
|
||||
user_with_tokens.update!(name: 'New Name')
|
||||
|
||||
expect(user_with_tokens.user_sessions.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'destroys all user_sessions when tokens is cleared' do
|
||||
user_with_tokens.tokens = {}
|
||||
|
||||
expect { user_with_tokens.save! }.to change(user_with_tokens.user_sessions, :count).by(-2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Profile Sessions API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:auth_headers) { user.create_new_auth_token }
|
||||
let(:current_client_id) { auth_headers['client'] }
|
||||
|
||||
describe 'GET /api/v1/profile/sessions' do
|
||||
it 'returns 401 without auth' do
|
||||
get '/api/v1/profile/sessions', as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns the current user sessions ordered by last_activity_at desc' do
|
||||
older = user.user_sessions.create!(client_id: current_client_id, browser_name: 'Chrome', last_activity_at: 2.days.ago)
|
||||
newer = user.user_sessions.create!(client_id: 'other-client', browser_name: 'Firefox', last_activity_at: 1.hour.ago)
|
||||
user.update!(tokens: user.tokens.merge('other-client' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i }))
|
||||
|
||||
get '/api/v1/profile/sessions', headers: auth_headers, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
sessions = response.parsed_body
|
||||
expect(sessions.map { |s| s['id'] }).to eq([newer.id, older.id])
|
||||
expect(sessions.find { |s| s['id'] == older.id }['current']).to be true
|
||||
expect(sessions.find { |s| s['id'] == newer.id }['current']).to be false
|
||||
end
|
||||
|
||||
it 'excludes sessions whose token has expired' do
|
||||
live = user.user_sessions.create!(client_id: current_client_id, last_activity_at: 1.hour.ago)
|
||||
expired = user.user_sessions.create!(client_id: 'expired-client', last_activity_at: 1.day.ago)
|
||||
user.update!(tokens: user.tokens.merge('expired-client' => { 'token' => 'x', 'expiry' => 1.day.ago.to_i }))
|
||||
|
||||
get '/api/v1/profile/sessions', headers: auth_headers, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
ids = response.parsed_body.map { |s| s['id'] }
|
||||
expect(ids).to include(live.id)
|
||||
expect(ids).not_to include(expired.id)
|
||||
end
|
||||
|
||||
it 'returns an empty array when no sessions exist' do
|
||||
get '/api/v1/profile/sessions', headers: auth_headers, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/profile/sessions/:id' do
|
||||
let!(:other_session) { user.user_sessions.create!(client_id: 'other-client', last_activity_at: 1.hour.ago) }
|
||||
|
||||
before do
|
||||
# Seed tokens hash so revoke can clean it up
|
||||
user.tokens = user.tokens.merge('other-client' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i })
|
||||
user.save!
|
||||
end
|
||||
|
||||
it 'destroys the session and removes its token entry' do
|
||||
expect do
|
||||
delete "/api/v1/profile/sessions/#{other_session.id}", headers: auth_headers, as: :json
|
||||
end.to change(user.user_sessions, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(user.reload.tokens.keys).not_to include('other-client')
|
||||
end
|
||||
|
||||
it 'returns 422 when trying to revoke the current session' do
|
||||
current = user.user_sessions.create!(client_id: current_client_id, last_activity_at: Time.current)
|
||||
|
||||
delete "/api/v1/profile/sessions/#{current.id}", headers: auth_headers, as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to be_present
|
||||
expect(user.user_sessions.exists?(id: current.id)).to be true
|
||||
end
|
||||
|
||||
it 'returns 404 for a nonexistent session id' do
|
||||
delete '/api/v1/profile/sessions/9999999', headers: auth_headers, as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'does not allow revoking another user' do
|
||||
other_user = create(:user, account: account)
|
||||
foreign = other_user.user_sessions.create!(client_id: 'foreign', last_activity_at: 1.hour.ago)
|
||||
|
||||
delete "/api/v1/profile/sessions/#{foreign.id}", headers: auth_headers, as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(other_user.user_sessions.exists?(id: foreign.id)).to be true
|
||||
end
|
||||
|
||||
it 'returns 401 without auth' do
|
||||
delete "/api/v1/profile/sessions/#{other_session.id}", as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,82 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe UserSessionTrackingService do
|
||||
let(:user) { create(:user) }
|
||||
let(:client_id) { 'client-abc' }
|
||||
let(:request) do
|
||||
instance_double(
|
||||
ActionDispatch::Request,
|
||||
user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15',
|
||||
remote_ip: '8.8.8.8'
|
||||
)
|
||||
end
|
||||
let(:service) { described_class.new(user: user, request: request, client_id: client_id) }
|
||||
|
||||
describe '#create_or_update!' do
|
||||
it 'creates a new UserSession with the right client_id and timestamps' do
|
||||
expect { service.create_or_update! }.to change(user.user_sessions, :count).by(1)
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.client_id).to eq(client_id)
|
||||
expect(session.last_activity_at).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
|
||||
it 'populates request and browser metadata synchronously', :aggregate_failures do
|
||||
service.create_or_update!
|
||||
|
||||
session = user.user_sessions.last
|
||||
expect(session.ip_address).to eq('8.8.8.8')
|
||||
expect(session.browser_name).to eq('Safari')
|
||||
expect(session.platform_name).to eq('macOS')
|
||||
end
|
||||
|
||||
it 'does not call IpLookupService synchronously' do
|
||||
expect(IpLookupService).not_to receive(:new)
|
||||
|
||||
service.create_or_update!
|
||||
end
|
||||
|
||||
it 'enqueues UserSessionIpLookupJob to backfill geo data' do
|
||||
expect { service.create_or_update! }.to have_enqueued_job(UserSessionIpLookupJob)
|
||||
end
|
||||
|
||||
it 'updates an existing session when client_id matches' do
|
||||
existing = user.user_sessions.create!(client_id: client_id, ip_address: '1.1.1.1', last_activity_at: 1.day.ago)
|
||||
|
||||
expect { service.create_or_update! }.not_to change(user.user_sessions, :count)
|
||||
expect(existing.reload.ip_address).to eq('8.8.8.8')
|
||||
expect(existing.last_activity_at).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#update_activity!' do
|
||||
it 'does nothing when no session exists for the client_id' do
|
||||
expect { service.update_activity! }.not_to change(user.user_sessions, :count)
|
||||
end
|
||||
|
||||
it 'does nothing when the session was recently active' do
|
||||
session = user.user_sessions.create!(client_id: client_id, last_activity_at: 1.minute.ago)
|
||||
before_ts = session.last_activity_at
|
||||
|
||||
service.update_activity!
|
||||
|
||||
expect(session.reload.last_activity_at).to be_within(1.second).of(before_ts)
|
||||
end
|
||||
|
||||
it 'bumps last_activity_at when the session is stale' do
|
||||
session = user.user_sessions.create!(client_id: client_id, last_activity_at: 10.minutes.ago)
|
||||
|
||||
service.update_activity!
|
||||
|
||||
expect(session.reload.last_activity_at).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
|
||||
it 'bumps last_activity_at when last_activity_at is nil' do
|
||||
session = user.user_sessions.create!(client_id: client_id, last_activity_at: nil)
|
||||
|
||||
service.update_activity!
|
||||
|
||||
expect(session.reload.last_activity_at).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -177,7 +177,7 @@ describe Whatsapp::FacebookApiClient do
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { override_callback_uri: callback_url, verify_token: verify_token,
|
||||
subscribed_fields: %w[messages smb_message_echoes calls] }.to_json
|
||||
subscribed_fields: %w[messages smb_message_echoes] }.to_json
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
@@ -224,7 +224,7 @@ describe Whatsapp::FacebookApiClient do
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { override_callback_uri: callback_url, verify_token: verify_token,
|
||||
subscribed_fields: %w[messages smb_message_echoes calls] }.to_json
|
||||
subscribed_fields: %w[messages smb_message_echoes] }.to_json
|
||||
)
|
||||
.to_return(status: 400, body: { error: 'Webhook callback override failed' }.to_json)
|
||||
end
|
||||
|
||||
@@ -43,7 +43,7 @@ describe Whatsapp::WebhookSetupService do
|
||||
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
|
||||
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
|
||||
.with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
|
||||
allow(channel).to receive(:save!)
|
||||
end
|
||||
|
||||
@@ -51,7 +51,8 @@ describe Whatsapp::WebhookSetupService do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages
|
||||
smb_message_echoes])
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
@@ -65,14 +66,15 @@ describe Whatsapp::WebhookSetupService do
|
||||
throughput: { level: 'APPLICABLE' }
|
||||
})
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
|
||||
.with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
|
||||
end
|
||||
|
||||
it 'does NOT register phone, but sets up webhook' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).not_to receive(:register_phone_number)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages
|
||||
smb_message_echoes])
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
@@ -88,7 +90,7 @@ describe Whatsapp::WebhookSetupService do
|
||||
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
|
||||
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
|
||||
.with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
|
||||
allow(channel).to receive(:save!)
|
||||
end
|
||||
|
||||
@@ -96,7 +98,8 @@ describe Whatsapp::WebhookSetupService do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages
|
||||
smb_message_echoes])
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
@@ -112,7 +115,7 @@ describe Whatsapp::WebhookSetupService do
|
||||
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
|
||||
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
|
||||
.with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
|
||||
allow(channel).to receive(:save!)
|
||||
end
|
||||
|
||||
@@ -120,7 +123,8 @@ describe Whatsapp::WebhookSetupService do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages
|
||||
smb_message_echoes])
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
@@ -279,14 +283,15 @@ describe Whatsapp::WebhookSetupService do
|
||||
throughput: { level: 'APPLICABLE' }
|
||||
})
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'existing_verify_token').and_return({ 'success' => true })
|
||||
.with(waba_id, anything, 'existing_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
|
||||
end
|
||||
|
||||
it 'successfully reauthorizes with new access token' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).not_to receive(:register_phone_number)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'existing_verify_token')
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'existing_verify_token',
|
||||
subscribed_fields: %w[messages smb_message_echoes])
|
||||
service_reauth.perform
|
||||
end
|
||||
end
|
||||
@@ -294,7 +299,7 @@ describe Whatsapp::WebhookSetupService do
|
||||
it 'uses the existing webhook verify token during reauthorization' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'existing_verify_token')
|
||||
.with(waba_id, anything, 'existing_verify_token', subscribed_fields: %w[messages smb_message_echoes])
|
||||
service_reauth.perform
|
||||
end
|
||||
end
|
||||
@@ -308,7 +313,7 @@ describe Whatsapp::WebhookSetupService do
|
||||
throughput: { level: 'APPLICABLE' }
|
||||
})
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
|
||||
.with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true })
|
||||
end
|
||||
|
||||
it 'completes successfully without errors' do
|
||||
|
||||
Reference in New Issue
Block a user