HH-547: allow cross-origin widget requests (#126)
* HH-547: allow cross-origin widget requests * fix(HH-547): align production preflight with wildcard CORS --------- Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -4,7 +4,6 @@ GOCHAT_IMAGE_REF=ghcr.io/rogeecn/gochat@sha256:CHANGE_ME
|
|||||||
GOCHAT_ENV=production
|
GOCHAT_ENV=production
|
||||||
GOCHAT_PORT=3000
|
GOCHAT_PORT=3000
|
||||||
GOCHAT_SERVER_MODE=release
|
GOCHAT_SERVER_MODE=release
|
||||||
GOCHAT_SERVER_CORS_ALLOWED_ORIGINS=https://chat.CHANGE_ME.example.com
|
|
||||||
GOCHAT_SERVER_TRUSTED_PROXIES=10.0.0.0/8
|
GOCHAT_SERVER_TRUSTED_PROXIES=10.0.0.0/8
|
||||||
GOCHAT_DATABASE_DSN=postgres://gochat:CHANGE_ME@db.CHANGE_ME.example.com:5432/gochat_production?sslmode=verify-full
|
GOCHAT_DATABASE_DSN=postgres://gochat:CHANGE_ME@db.CHANGE_ME.example.com:5432/gochat_production?sslmode=verify-full
|
||||||
GOCHAT_REDIS_DSN=redis://redis:6379/1
|
GOCHAT_REDIS_DSN=redis://redis:6379/1
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
# GoChat production overrides. Secrets and public origins must come from the environment.
|
# GoChat production overrides. Secrets must come from the environment.
|
||||||
server:
|
server:
|
||||||
mode: "release"
|
mode: "release"
|
||||||
cors:
|
|
||||||
allowed_origins: ["https://CHANGE_ME.example.com"]
|
|
||||||
allow_credentials: true
|
|
||||||
|
|
||||||
database:
|
database:
|
||||||
dsn: "postgres://gochat:CHANGE_ME@postgres:5432/gochat_production?sslmode=disable"
|
dsn: "postgres://gochat:CHANGE_ME@postgres:5432/gochat_production?sslmode=disable"
|
||||||
|
|||||||
@@ -10,14 +10,11 @@ server:
|
|||||||
max_header_bytes: 1048576
|
max_header_bytes: 1048576
|
||||||
trusted_proxies: [] # add explicit reverse-proxy IPs/CIDRs; XFF is ignored otherwise
|
trusted_proxies: [] # add explicit reverse-proxy IPs/CIDRs; XFF is ignored otherwise
|
||||||
cors:
|
cors:
|
||||||
allowed_origins: [] # empty = Allow-Origin:* in debug mode; production must list exact origins
|
allowed_origins: [] # retained for config compatibility; GoChat allows all origins
|
||||||
# Examples:
|
|
||||||
# - "https://app.example.com"
|
|
||||||
# - "*.example.com"
|
|
||||||
allowed_methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
|
allowed_methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
|
||||||
allowed_headers: ["Origin", "Content-Type", "Accept", "Authorization", "X-Account-ID", "access-token", "client", "uid", "token-type", "expiry"]
|
allowed_headers: ["Origin", "Content-Type", "Accept", "Authorization", "X-Account-ID", "X-Auth-Token", "X-Widget-Token", "X-Identifier-Hash", "access-token", "client", "uid", "token-type", "expiry"]
|
||||||
expose_headers: ["Content-Length", "access-token", "client", "uid", "token-type", "expiry"]
|
expose_headers: ["Content-Length", "access-token", "client", "uid", "token-type", "expiry"]
|
||||||
allow_credentials: false # set to true only if you need cookies/auth headers
|
allow_credentials: false # retained for config compatibility; wildcard CORS does not use credentials
|
||||||
max_age: 86400 # preflight cache duration in seconds
|
max_age: 86400 # preflight cache duration in seconds
|
||||||
|
|
||||||
database:
|
database:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func validReleaseConfig() *Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReleaseTransportAndCORSValidationFailsClosed(t *testing.T) {
|
func TestReleaseTransportValidationFailsClosed(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
mutate func(*Config)
|
mutate func(*Config)
|
||||||
@@ -30,9 +30,6 @@ func TestReleaseTransportAndCORSValidationFailsClosed(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"database TLS without identity verification", func(c *Config) { c.Database.DSN = "postgres://user:pass@db.acme.test/gochat?sslmode=require" }, "sslmode=disable, verify-full or verify-ca"},
|
{"database TLS without identity verification", func(c *Config) { c.Database.DSN = "postgres://user:pass@db.acme.test/gochat?sslmode=require" }, "sslmode=disable, verify-full or verify-ca"},
|
||||||
{"redis certificate not verified", func(c *Config) { c.Redis.DSN = "rediss://:redis-secret@redis.acme.test:6379?insecure_skip_verify=true" }, "cannot be disabled"},
|
{"redis certificate not verified", func(c *Config) { c.Redis.DSN = "rediss://:redis-secret@redis.acme.test:6379?insecure_skip_verify=true" }, "cannot be disabled"},
|
||||||
{"placeholder origin", func(c *Config) { c.Server.CORS.AllowedOrigins = []string{"https://example.com"} }, "not deployable"},
|
|
||||||
{"non HTTPS origin", func(c *Config) { c.Server.CORS.AllowedOrigins = []string{"http://chat.acme.test"} }, "exact HTTPS origin"},
|
|
||||||
{"wildcard origin", func(c *Config) { c.Server.CORS.AllowedOrigins = []string{"*.acme.test"} }, "exact HTTPS origin"},
|
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -136,19 +136,6 @@ func Validate(cfg *Config) error {
|
|||||||
if redisURL.Scheme == "rediss" && strings.EqualFold(redisURL.Query().Get("insecure_skip_verify"), "true") {
|
if redisURL.Scheme == "rediss" && strings.EqualFold(redisURL.Query().Get("insecure_skip_verify"), "true") {
|
||||||
return fmt.Errorf("production Redis TLS certificate verification cannot be disabled")
|
return fmt.Errorf("production Redis TLS certificate verification cannot be disabled")
|
||||||
}
|
}
|
||||||
if len(cfg.Server.CORS.AllowedOrigins) == 0 {
|
|
||||||
return fmt.Errorf("production CORS requires at least one HTTPS origin")
|
|
||||||
}
|
|
||||||
for _, origin := range cfg.Server.CORS.AllowedOrigins {
|
|
||||||
parsed, err := url.Parse(origin)
|
|
||||||
lower := strings.ToLower(origin)
|
|
||||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
||||||
return fmt.Errorf("production CORS origin must be an exact HTTPS origin: %q", origin)
|
|
||||||
}
|
|
||||||
if strings.Contains(origin, "*") || strings.Contains(lower, "localhost") || strings.Contains(lower, "example.") || strings.Contains(lower, "yourdomain") || containsPlaceholder(origin) {
|
|
||||||
return fmt.Errorf("production CORS origin is not deployable: %q", origin)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cfg.JWT.AccessExpiryMinutes <= 0 || cfg.JWT.AccessExpiryMinutes > 15 {
|
if cfg.JWT.AccessExpiryMinutes <= 0 || cfg.JWT.AccessExpiryMinutes > 15 {
|
||||||
return fmt.Errorf("production JWT access_expiry_minutes must be between 1 and 15")
|
return fmt.Errorf("production JWT access_expiry_minutes must be between 1 and 15")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type CORSConfig struct {
|
|||||||
|
|
||||||
// Default CORS values for production.
|
// Default CORS values for production.
|
||||||
var defaultCORSMethods = []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}
|
var defaultCORSMethods = []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}
|
||||||
var defaultCORSHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Account-ID", "access-token", "client", "uid", "token-type", "expiry"}
|
var defaultCORSHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Account-ID", "X-Auth-Token", "X-Widget-Token", "X-Identifier-Hash", "access-token", "client", "uid", "token-type", "expiry"}
|
||||||
var defaultCORSExposeHeaders = []string{"Content-Length", "access-token", "client", "uid", "token-type", "expiry"}
|
var defaultCORSExposeHeaders = []string{"Content-Length", "access-token", "client", "uid", "token-type", "expiry"}
|
||||||
var defaultCORSMaxAge = 86400 // 24 hours
|
var defaultCORSMaxAge = 86400 // 24 hours
|
||||||
|
|
||||||
@@ -170,16 +170,13 @@ func extractHost(origin string) string {
|
|||||||
return u.Hostname()
|
return u.Hostname()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CORSConfigFromAppConfig creates a middleware CORSConfig from the application config.
|
// CORSConfigFromAppConfig keeps method/header tuning while allowing every embed origin.
|
||||||
func CORSConfigFromAppConfig(cfg *config.Config) CORSConfig {
|
func CORSConfigFromAppConfig(cfg *config.Config) CORSConfig {
|
||||||
devMode := cfg.Server.Mode == "debug"
|
|
||||||
return CORSConfig{
|
return CORSConfig{
|
||||||
AllowedOrigins: cfg.Server.CORS.AllowedOrigins,
|
AllowedMethods: cfg.Server.CORS.AllowedMethods,
|
||||||
AllowedMethods: cfg.Server.CORS.AllowedMethods,
|
AllowedHeaders: cfg.Server.CORS.AllowedHeaders,
|
||||||
AllowedHeaders: cfg.Server.CORS.AllowedHeaders,
|
ExposeHeaders: cfg.Server.CORS.ExposeHeaders,
|
||||||
ExposeHeaders: cfg.Server.CORS.ExposeHeaders,
|
MaxAge: cfg.Server.CORS.MaxAge,
|
||||||
AllowCredentials: cfg.Server.CORS.AllowCredentials,
|
DevMode: true,
|
||||||
MaxAge: cfg.Server.CORS.MaxAge,
|
|
||||||
DevMode: devMode,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,11 +58,38 @@ func TestCORS_DefaultAllowedHeadersIncludeChatwootAuthTokens(t *testing.T) {
|
|||||||
router.ServeHTTP(w, req)
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
allowedHeaders := w.Header().Get("Access-Control-Allow-Headers")
|
allowedHeaders := w.Header().Get("Access-Control-Allow-Headers")
|
||||||
for _, header := range []string{"access-token", "client", "uid", "token-type", "expiry"} {
|
for _, header := range []string{"access-token", "client", "uid", "token-type", "expiry", "X-Auth-Token", "X-Widget-Token", "X-Identifier-Hash"} {
|
||||||
assert.Contains(t, allowedHeaders, header)
|
assert.Contains(t, allowedHeaders, header)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCORSConfigFromAppConfig_AllowsWidgetPreflightFromAnyProductionOrigin(t *testing.T) {
|
||||||
|
cfg := &config.Config{Server: config.ServerConfig{
|
||||||
|
Mode: "release",
|
||||||
|
CORS: config.CORSConfig{
|
||||||
|
AllowedOrigins: []string{"https://gochat.example.com"},
|
||||||
|
AllowCredentials: true,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(CORS(CORSConfigFromAppConfig(cfg)))
|
||||||
|
router.POST("/api/v1/widget/conversations/toggle_typing", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/api/v1/widget/conversations/toggle_typing", nil)
|
||||||
|
req.Header.Set("Origin", "https://embedded.example.net")
|
||||||
|
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
||||||
|
req.Header.Set("Access-Control-Request-Headers", "X-Auth-Token, X-Widget-Token, X-Identifier-Hash")
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNoContent, w.Code)
|
||||||
|
assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin"))
|
||||||
|
for _, header := range []string{"X-Auth-Token", "X-Widget-Token", "X-Identifier-Hash"} {
|
||||||
|
assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), header)
|
||||||
|
}
|
||||||
|
assert.Empty(t, w.Header().Get("Access-Control-Allow-Credentials"))
|
||||||
|
}
|
||||||
|
|
||||||
func TestCORS_DefaultExposeHeadersIncludeChatwootAuthTokens(t *testing.T) {
|
func TestCORS_DefaultExposeHeadersIncludeChatwootAuthTokens(t *testing.T) {
|
||||||
cfg := CORSConfig{DevMode: true}
|
cfg := CORSConfig{DevMode: true}
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
@@ -316,8 +343,8 @@ func TestCORSConfigFromAppConfig_DebugMode(t *testing.T) {
|
|||||||
|
|
||||||
mwCfg := CORSConfigFromAppConfig(cfg)
|
mwCfg := CORSConfigFromAppConfig(cfg)
|
||||||
assert.True(t, mwCfg.DevMode)
|
assert.True(t, mwCfg.DevMode)
|
||||||
assert.Equal(t, []string{"https://app.example.com"}, mwCfg.AllowedOrigins)
|
assert.Empty(t, mwCfg.AllowedOrigins)
|
||||||
assert.True(t, mwCfg.AllowCredentials)
|
assert.False(t, mwCfg.AllowCredentials)
|
||||||
assert.Equal(t, 3600, mwCfg.MaxAge)
|
assert.Equal(t, 3600, mwCfg.MaxAge)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,12 +364,12 @@ func TestCORSConfigFromAppConfig_ProductionMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mwCfg := CORSConfigFromAppConfig(cfg)
|
mwCfg := CORSConfigFromAppConfig(cfg)
|
||||||
assert.False(t, mwCfg.DevMode)
|
assert.True(t, mwCfg.DevMode)
|
||||||
assert.Equal(t, []string{"https://app.example.com", "*.internal.com"}, mwCfg.AllowedOrigins)
|
assert.Empty(t, mwCfg.AllowedOrigins)
|
||||||
assert.Equal(t, []string{"GET", "POST", "PUT"}, mwCfg.AllowedMethods)
|
assert.Equal(t, []string{"GET", "POST", "PUT"}, mwCfg.AllowedMethods)
|
||||||
assert.Equal(t, []string{"Authorization", "Content-Type"}, mwCfg.AllowedHeaders)
|
assert.Equal(t, []string{"Authorization", "Content-Type"}, mwCfg.AllowedHeaders)
|
||||||
assert.Equal(t, []string{"X-Total-Count"}, mwCfg.ExposeHeaders)
|
assert.Equal(t, []string{"X-Total-Count"}, mwCfg.ExposeHeaders)
|
||||||
assert.True(t, mwCfg.AllowCredentials)
|
assert.False(t, mwCfg.AllowCredentials)
|
||||||
assert.Equal(t, 7200, mwCfg.MaxAge)
|
assert.Equal(t, 7200, mwCfg.MaxAge)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,7 +382,7 @@ func TestCORSConfigFromAppConfig_EmptyCORS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mwCfg := CORSConfigFromAppConfig(cfg)
|
mwCfg := CORSConfigFromAppConfig(cfg)
|
||||||
assert.False(t, mwCfg.DevMode)
|
assert.True(t, mwCfg.DevMode)
|
||||||
assert.Empty(t, mwCfg.AllowedOrigins)
|
assert.Empty(t, mwCfg.AllowedOrigins)
|
||||||
assert.Empty(t, mwCfg.AllowedMethods) // defaults applied in CORS() middleware, not here
|
assert.Empty(t, mwCfg.AllowedMethods) // defaults applied in CORS() middleware, not here
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2018,7 +2018,7 @@ func TestCORSConfigFromAppConfig_Cov7(t *testing.T) {
|
|||||||
cfg.Server.CORS.AllowedOrigins = []string{"https://app.com"}
|
cfg.Server.CORS.AllowedOrigins = []string{"https://app.com"}
|
||||||
result := CORSConfigFromAppConfig(cfg)
|
result := CORSConfigFromAppConfig(cfg)
|
||||||
assert.True(t, result.DevMode)
|
assert.True(t, result.DevMode)
|
||||||
assert.Equal(t, []string{"https://app.com"}, result.AllowedOrigins)
|
assert.Empty(t, result.AllowedOrigins)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================
|
// ===========================
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ x-gochat-environment: &gochat-environment
|
|||||||
GOCHAT_SERVER_HOST: 0.0.0.0
|
GOCHAT_SERVER_HOST: 0.0.0.0
|
||||||
GOCHAT_SERVER_PORT: 3000
|
GOCHAT_SERVER_PORT: 3000
|
||||||
GOCHAT_SERVER_MODE: release
|
GOCHAT_SERVER_MODE: release
|
||||||
GOCHAT_SERVER_CORS_ALLOWED_ORIGINS: ${GOCHAT_SERVER_CORS_ALLOWED_ORIGINS:?set production CORS origins}
|
|
||||||
GOCHAT_DATABASE_DSN: &gochat-database-dsn ${GOCHAT_DATABASE_DSN:?set an external PostgreSQL DSN with an explicit sslmode}
|
GOCHAT_DATABASE_DSN: &gochat-database-dsn ${GOCHAT_DATABASE_DSN:?set an external PostgreSQL DSN with an explicit sslmode}
|
||||||
GOCHAT_DATABASE_RUN_MIGRATIONS: "false"
|
GOCHAT_DATABASE_RUN_MIGRATIONS: "false"
|
||||||
GOCHAT_DATABASE_MIGRATIONS_PATH: /app/migrations
|
GOCHAT_DATABASE_MIGRATIONS_PATH: /app/migrations
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ if (($#)); then
|
|||||||
compose_args=(--env-file "$env_file" "${compose_args[@]}")
|
compose_args=(--env-file "$env_file" "${compose_args[@]}")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
required=(GOCHAT_IMAGE_REF SHANGWUTONG_IMAGE_REF GOCHAT_SERVER_CORS_ALLOWED_ORIGINS GOCHAT_DATABASE_DSN GOCHAT_REDIS_DSN MEILI_MASTER_KEY GOCHAT_JWT_SECRET GOCHAT_BACKUP_OFFSITE_DIR GOCHAT_BACKUP_OFFSITE_SOURCE GOCHAT_BACKUP_OFFSITE_FSTYPE)
|
required=(GOCHAT_IMAGE_REF SHANGWUTONG_IMAGE_REF GOCHAT_DATABASE_DSN GOCHAT_REDIS_DSN MEILI_MASTER_KEY GOCHAT_JWT_SECRET GOCHAT_BACKUP_OFFSITE_DIR GOCHAT_BACKUP_OFFSITE_SOURCE GOCHAT_BACKUP_OFFSITE_FSTYPE)
|
||||||
for name in "${required[@]}"; do
|
for name in "${required[@]}"; do
|
||||||
value=${!name:-}
|
value=${!name:-}
|
||||||
if [[ -z $value || ${value^^} == *CHANGE_ME* ]]; then
|
if [[ -z $value || ${value^^} == *CHANGE_ME* ]]; then
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ chmod +x "$tmp/bin/findmnt" "$tmp/bin/docker"
|
|||||||
export PATH="$tmp/bin:$PATH"
|
export PATH="$tmp/bin:$PATH"
|
||||||
export GOCHAT_IMAGE_REF='gochat@example.invalid/gochat@sha256:0000000000000000000000000000000000000000000000000000000000000000'
|
export GOCHAT_IMAGE_REF='gochat@example.invalid/gochat@sha256:0000000000000000000000000000000000000000000000000000000000000000'
|
||||||
export SHANGWUTONG_IMAGE_REF='gochat@example.invalid/connector@sha256:1111111111111111111111111111111111111111111111111111111111111111'
|
export SHANGWUTONG_IMAGE_REF='gochat@example.invalid/connector@sha256:1111111111111111111111111111111111111111111111111111111111111111'
|
||||||
export GOCHAT_SERVER_CORS_ALLOWED_ORIGINS=https://chat.ci.rogeecn.com
|
unset GOCHAT_SERVER_CORS_ALLOWED_ORIGINS
|
||||||
export GOCHAT_DATABASE_DSN='postgres://external_user:external_password@db.example.test:5432/gochat?sslmode=verify-full'
|
export GOCHAT_DATABASE_DSN='postgres://external_user:external_password@db.example.test:5432/gochat?sslmode=verify-full'
|
||||||
export GOCHAT_REDIS_DSN='rediss://:ci-redis-secret@redis.example.test:6379/0'
|
export GOCHAT_REDIS_DSN='rediss://:ci-redis-secret@redis.example.test:6379/0'
|
||||||
export POSTGRES_PASSWORD=ci-postgres-secret
|
export POSTGRES_PASSWORD=ci-postgres-secret
|
||||||
|
|||||||
@@ -186,7 +186,8 @@ export const actions = {
|
|||||||
commit('toggleAgentTypingStatus', data);
|
commit('toggleAgentTypingStatus', data);
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleUserTyping: async (_, data) => {
|
toggleUserTyping: async ({ getters }, data) => {
|
||||||
|
if (!getters.getConversationSize) return;
|
||||||
try {
|
try {
|
||||||
await toggleTyping(data);
|
await toggleTyping(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -206,6 +206,34 @@ describe('#actions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#toggleUserTyping', () => {
|
||||||
|
it('does not request typing before a conversation exists', async () => {
|
||||||
|
API.post.mockClear();
|
||||||
|
|
||||||
|
await actions.toggleUserTyping(
|
||||||
|
{ getters: { getConversationSize: 0 } },
|
||||||
|
{ typingStatus: 'on' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(API.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests typing when a conversation exists', async () => {
|
||||||
|
API.post.mockClear();
|
||||||
|
API.post.mockResolvedValue({ data: {} });
|
||||||
|
|
||||||
|
await actions.toggleUserTyping(
|
||||||
|
{ getters: { getConversationSize: 1 } },
|
||||||
|
{ typingStatus: 'on' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(API.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/widget/conversations/toggle_typing',
|
||||||
|
{ typing_status: 'on' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('#setCustomAttributes', () => {
|
describe('#setCustomAttributes', () => {
|
||||||
it('queues to pending state when no conversation exists', async () => {
|
it('queues to pending state when no conversation exists', async () => {
|
||||||
const rootGetters = {
|
const rootGetters = {
|
||||||
|
|||||||
Reference in New Issue
Block a user