Files
team/server/channels/app/guarded_hooks_test.go
Christopher PoileandGitHub 726d731a66 [MM-68620] MBE Phase 13: channel-guard enforcement for scheduled posts & drafts (#36961)
* add guards to ScheduledPost* and Draft*; update version availability

* make i18n strings extractable

* PR comments, much simplification
2026-06-16 20:09:39 -04:00

626 lines
23 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"net/http"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
// guardPluginRejectsAll generates a plugin that implements ScheduledPostWillBeCreated and
// DraftWillBeUpserted to reject every call with rejectReason. It does NOT call
// RegisterChannelGuard in OnActivate; the test registers the guard via th.App.RegisterChannelGuard
// after activation (matching the TestChannelGuardBlocksPostWhenPluginInactive pattern).
func guardPluginRejectsAll(rejectReason string) string {
return `
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) ScheduledPostWillBeCreated(c *plugin.Context, sp *model.ScheduledPost) (*model.ScheduledPost, string) {
return nil, "` + rejectReason + `"
}
func (p *MyPlugin) DraftWillBeUpserted(c *plugin.Context, draft *model.Draft) (*model.Draft, string) {
return nil, "` + rejectReason + `"
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`
}
// guardPluginRegistersOnly generates a plugin that only registers a channel guard on OnActivate.
// Used for the inactive-guard tests: the guard row persists after the plugin is deactivated,
// allowing the test to verify the fail-closed rejection.
func guardPluginRegistersOnly(channelID string) string {
return `
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) OnActivate() error {
return p.API.RegisterChannelGuard("` + channelID + `")
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`
}
// enableDrafts turns on AllowSyncedDrafts and returns a cleanup that restores the read-only
// feature-flag state. Drafts are gated behind that flag, so the draft guard tests must enable it.
func enableDrafts(th *TestHelper) func() {
th.Server.platform.SetConfigReadOnlyFF(false)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
return func() { th.Server.platform.SetConfigReadOnlyFF(true) }
}
// seedGuardCache directly populates the Channels guard cache for unit tests that
// need guards without going through the full DB round-trip.
func seedGuardCache(th *TestHelper, channelID string, guards []*store.ChannelGuard) {
m := &sync.Map{}
if len(guards) > 0 {
m.Store(channelID, guards)
}
th.App.Channels().guardCache.Store(m)
}
func TestResolveGuards(t *testing.T) {
t.Run("no guards returns nil nil", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Empty cache — channel has no guard rows.
seedGuardCache(th, th.BasicChannel.Id, nil)
rctx := request.EmptyContext(th.App.Srv().Log())
guards, rejectErr := th.App.resolveGuards(rctx, th.BasicChannel.Id, "test")
require.Nil(t, rejectErr)
require.Nil(t, guards)
})
t.Run("cache uninitialized returns nil nil", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Store a nil *sync.Map — models the brief window before the first reload.
th.App.Channels().guardCache.Store((*sync.Map)(nil))
rctx := request.EmptyContext(th.App.Srv().Log())
guards, rejectErr := th.App.resolveGuards(rctx, th.BasicChannel.Id, "test")
require.Nil(t, rejectErr)
require.Nil(t, guards)
})
t.Run("guards are sorted by PluginId", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Insert guards in reverse alphabetical order; resolveGuards must return them sorted.
unsorted := []*store.ChannelGuard{
{ChannelId: th.BasicChannel.Id, PluginId: "zzz.plugin"},
{ChannelId: th.BasicChannel.Id, PluginId: "aaa.plugin"},
{ChannelId: th.BasicChannel.Id, PluginId: "mmm.plugin"},
}
seedGuardCache(th, th.BasicChannel.Id, unsorted)
// All plugin IDs are unknown to the environment → IsActive returns false for each.
// Disable plugins so resolveGuards hits the env==nil branch instead.
// We only want to test sort order, so use a trick: temporarily disable plugins to
// get through the env==nil fast-path and confirm the sorted slice is built before
// the env check. Actually env==nil returns early with the sorted slice — that's
// correct behaviour to assert sort order.
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = false })
rctx := request.EmptyContext(th.App.Srv().Log())
guards, rejectErr := th.App.resolveGuards(rctx, th.BasicChannel.Id, "test")
// env==nil → reject is non-nil, but guards slice must still be sorted.
require.NotNil(t, rejectErr, "plugins disabled + guards exist → expect reject error")
require.Len(t, guards, 3)
assert.Equal(t, "aaa.plugin", guards[0].PluginId)
assert.Equal(t, "mmm.plugin", guards[1].PluginId)
assert.Equal(t, "zzz.plugin", guards[2].PluginId)
})
t.Run("single inactive plugin returns reject error", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Seed one guard with a plugin ID that is not active in the environment.
fakePlugin := "com.example.inactive-single"
seedGuardCache(th, th.BasicChannel.Id, []*store.ChannelGuard{
{ChannelId: th.BasicChannel.Id, PluginId: fakePlugin},
})
rctx := request.EmptyContext(th.App.Srv().Log())
guards, rejectErr := th.App.resolveGuards(rctx, th.BasicChannel.Id, "callerA")
require.NotNil(t, rejectErr)
assert.Equal(t, "app.plugin.inactive_guard.app_error", rejectErr.Id)
assert.Equal(t, http.StatusServiceUnavailable, rejectErr.StatusCode)
// Guards slice is returned even on reject so callers can log the full context.
require.Len(t, guards, 1)
assert.Equal(t, fakePlugin, guards[0].PluginId)
})
t.Run("multiple inactive plugins returns reject error", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Two inactive guards — exercises the mlog.Array path in logAndErrPluginInactive.
seedGuardCache(th, th.BasicChannel.Id, []*store.ChannelGuard{
{ChannelId: th.BasicChannel.Id, PluginId: "com.example.inactive-a"},
{ChannelId: th.BasicChannel.Id, PluginId: "com.example.inactive-b"},
})
rctx := request.EmptyContext(th.App.Srv().Log())
guards, rejectErr := th.App.resolveGuards(rctx, th.BasicChannel.Id, "callerB")
require.NotNil(t, rejectErr)
assert.Equal(t, "app.plugin.inactive_guard.app_error", rejectErr.Id)
assert.Equal(t, http.StatusServiceUnavailable, rejectErr.StatusCode)
require.Len(t, guards, 2)
})
t.Run("env nil branch returns reject error", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
fakePlugin := "com.example.env-nil"
seedGuardCache(th, th.BasicChannel.Id, []*store.ChannelGuard{
{ChannelId: th.BasicChannel.Id, PluginId: fakePlugin},
})
// Disable the plugin system so GetPluginsEnvironment returns nil.
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = false })
rctx := request.EmptyContext(th.App.Srv().Log())
guards, rejectErr := th.App.resolveGuards(rctx, th.BasicChannel.Id, "callerC")
require.NotNil(t, rejectErr)
assert.Equal(t, "app.plugin.inactive_guard.app_error", rejectErr.Id)
assert.Equal(t, http.StatusServiceUnavailable, rejectErr.StatusCode)
// Guards slice is still populated with the sorted rows.
require.Len(t, guards, 1)
})
}
func TestPluginIDsOf(t *testing.T) {
t.Run("nil input returns empty slice", func(t *testing.T) {
ids := pluginIDsOf(nil)
assert.Empty(t, ids)
})
t.Run("empty input returns empty slice", func(t *testing.T) {
ids := pluginIDsOf([]*store.ChannelGuard{})
assert.Empty(t, ids)
})
t.Run("multiple guards returns IDs in input order", func(t *testing.T) {
guards := []*store.ChannelGuard{
{PluginId: "aaa"},
{PluginId: "bbb"},
{PluginId: "ccc"},
}
ids := pluginIDsOf(guards)
require.Equal(t, []string{"aaa", "bbb", "ccc"}, ids)
})
}
func TestAppErrHookFailed(t *testing.T) {
t.Run("without error sets correct fields", func(t *testing.T) {
appErr := appErrHookFailed("com.example.plugin", "CreatePost", nil)
require.NotNil(t, appErr)
assert.Equal(t, "app.plugin.guard_hook_failed.app_error", appErr.Id)
assert.Equal(t, http.StatusServiceUnavailable, appErr.StatusCode)
// err==nil branch: no Wrap, so Unwrap returns nil.
assert.NoError(t, appErr.Unwrap())
})
t.Run("with error wraps it", func(t *testing.T) {
cause := errors.New("rpc transport failure")
appErr := appErrHookFailed("com.example.plugin", "UpdatePost", cause)
require.NotNil(t, appErr)
assert.Equal(t, "app.plugin.guard_hook_failed.app_error", appErr.Id)
assert.Equal(t, http.StatusServiceUnavailable, appErr.StatusCode)
// err!=nil branch: Wrap stores it; errors.Is traverses via Unwrap.
assert.ErrorIs(t, appErr, cause)
})
}
func TestLogAndErrPluginInactive(t *testing.T) {
t.Run("single plugin ID returns correct AppError", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
rctx := request.EmptyContext(th.App.Srv().Log())
appErr := logAndErrPluginInactive(rctx, "ch-id-1", []string{"com.example.only"}, "callerX")
require.NotNil(t, appErr)
assert.Equal(t, "app.plugin.inactive_guard.app_error", appErr.Id)
assert.Equal(t, http.StatusServiceUnavailable, appErr.StatusCode)
})
t.Run("multiple plugin IDs returns correct AppError", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
rctx := request.EmptyContext(th.App.Srv().Log())
appErr := logAndErrPluginInactive(rctx, "ch-id-2", []string{"com.a", "com.b", "com.c"}, "callerY")
require.NotNil(t, appErr)
assert.Equal(t, "app.plugin.inactive_guard.app_error", appErr.Id)
assert.Equal(t, http.StatusServiceUnavailable, appErr.StatusCode)
})
}
func TestLogAndErrPluginsDisabled(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
rctx := request.EmptyContext(th.App.Srv().Log())
appErr := logAndErrPluginsDisabled(rctx, "ch-id-3", "callerZ")
require.NotNil(t, appErr)
// Same user-visible error ID as inactive_guard (internal cause differs).
assert.Equal(t, "app.plugin.inactive_guard.app_error", appErr.Id)
assert.Equal(t, http.StatusServiceUnavailable, appErr.StatusCode)
}
// TestChannelGuardBlocksScheduledPostWhenPluginInactive verifies fail-closed enforcement on the
// SaveScheduledPost / UpdateScheduledPost paths. Three states are exercised per caller:
// (1) plugin active + hook rejects → 400 rejected_by_plugin, no row persisted
// (2) plugin inactive after guard registration → 503 inactive_guard, no row persisted
// (3) unguarded channel → passes through, row persists normally
func TestChannelGuardBlocksScheduledPostWhenPluginInactive(t *testing.T) {
mainHelper.Parallel(t)
// State 1: plugin active and hook rejects — SaveScheduledPost.
// The plugin does NOT call RegisterChannelGuard in OnActivate; the test registers the guard
// directly so the plugin process stays alive and can serve the hook RPC call.
t.Run("save rejected by active plugin", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t,
[]string{guardPluginRejectsAll("not permitted")},
th.App, th.NewPluginAPI,
)
defer tearDown()
require.NoError(t, errs[0])
pluginID := pluginIDs[0]
require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID))
appErr := th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, pluginID)
require.Nil(t, appErr, "RegisterChannelGuard must succeed")
sp := &model.ScheduledPost{
Draft: model.Draft{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "should be rejected",
},
ScheduledAt: model.GetMillis() + 60_000,
}
_, appErr = th.App.SaveScheduledPost(th.Context, sp, "")
require.NotNil(t, appErr)
assert.Contains(t, appErr.Id, "rejected_by_plugin")
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
// A rejection must not persist any row — same bar as the inactive-guard state.
rows, storeErr := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicTeam.Id)
require.NoError(t, storeErr)
for _, row := range rows {
assert.NotEqual(t, th.BasicChannel.Id, row.ChannelId, "rejected scheduled post must not be in the store")
}
})
// State 2: plugin inactive — SaveScheduledPost must return 503 and write no row.
// This plugin registers the guard in OnActivate; the guard row persists after deactivation.
t.Run("save blocked when guard plugin inactive", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t,
[]string{guardPluginRegistersOnly(th.BasicChannel.Id)},
th.App, th.NewPluginAPI,
)
defer tearDown()
require.NoError(t, errs[0])
pluginID := pluginIDs[0]
require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID))
require.True(t, th.App.GetPluginsEnvironment().Deactivate(pluginID))
require.False(t, th.App.GetPluginsEnvironment().IsActive(pluginID))
sp := &model.ScheduledPost{
Draft: model.Draft{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "plaintext must not persist",
},
ScheduledAt: model.GetMillis() + 60_000,
}
_, appErr := th.App.SaveScheduledPost(th.Context, sp, "")
require.NotNil(t, appErr)
assert.Equal(t, "app.plugin.inactive_guard.app_error", appErr.Id)
assert.Equal(t, http.StatusServiceUnavailable, appErr.StatusCode)
// No row may exist for this channel/user.
rows, storeErr := th.App.Srv().Store().ScheduledPost().GetScheduledPostsForUser(th.BasicUser.Id, th.BasicTeam.Id)
require.NoError(t, storeErr)
for _, row := range rows {
assert.NotEqual(t, th.BasicChannel.Id, row.ChannelId, "rejected scheduled post must not be in the store")
}
})
// State 3: unguarded channel — SaveScheduledPost passes through, row persists
t.Run("save passes on unguarded channel", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// No plugin loaded → no guard row → plain pass-through.
sp := &model.ScheduledPost{
Draft: model.Draft{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "unguarded, should persist",
},
ScheduledAt: model.GetMillis() + 60_000,
}
saved, appErr := th.App.SaveScheduledPost(th.Context, sp, "")
require.Nil(t, appErr)
require.NotNil(t, saved)
// Confirm the row actually exists in the store with the expected content.
fetched, storeErr := th.App.Srv().Store().ScheduledPost().Get(saved.Id)
require.NoError(t, storeErr)
assert.Equal(t, "unguarded, should persist", fetched.Message)
})
// State 1 (update): plugin active and hook rejects — UpdateScheduledPost.
// Guard registered via App method so the plugin process stays alive.
t.Run("update rejected by active plugin", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Save first (no plugin yet).
sp := &model.ScheduledPost{
Draft: model.Draft{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "original",
},
ScheduledAt: model.GetMillis() + 60_000,
}
saved, appErr := th.App.SaveScheduledPost(th.Context, sp, "")
require.Nil(t, appErr)
require.NotNil(t, saved)
tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t,
[]string{guardPluginRejectsAll("update not permitted")},
th.App, th.NewPluginAPI,
)
defer tearDown()
require.NoError(t, errs[0])
pluginID := pluginIDs[0]
require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID))
appErr = th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, pluginID)
require.Nil(t, appErr, "RegisterChannelGuard must succeed")
saved.Message = "edited"
_, appErr = th.App.UpdateScheduledPost(th.Context, th.BasicUser.Id, saved, "")
require.NotNil(t, appErr)
assert.Contains(t, appErr.Id, "rejected_by_plugin")
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
// The store row must still have the original message — the rejection must not mutate it.
fetched, storeErr := th.App.Srv().Store().ScheduledPost().Get(saved.Id)
require.NoError(t, storeErr)
assert.Equal(t, "original", fetched.Message, "rejected update must not be persisted")
})
// State 2 (update): plugin inactive — UpdateScheduledPost must return 503 and not persist.
// Guard registered in OnActivate; the row persists after deactivation.
t.Run("update blocked when guard plugin inactive", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Save the post before activating the guard plugin.
sp := &model.ScheduledPost{
Draft: model.Draft{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "original",
},
ScheduledAt: model.GetMillis() + 60_000,
}
saved, appErr := th.App.SaveScheduledPost(th.Context, sp, "")
require.Nil(t, appErr)
require.NotNil(t, saved)
tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t,
[]string{guardPluginRegistersOnly(th.BasicChannel.Id)},
th.App, th.NewPluginAPI,
)
defer tearDown()
require.NoError(t, errs[0])
pluginID := pluginIDs[0]
require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID))
require.True(t, th.App.GetPluginsEnvironment().Deactivate(pluginID))
require.False(t, th.App.GetPluginsEnvironment().IsActive(pluginID))
saved.Message = "should be rejected"
_, appErr = th.App.UpdateScheduledPost(th.Context, th.BasicUser.Id, saved, "")
require.NotNil(t, appErr)
assert.Equal(t, "app.plugin.inactive_guard.app_error", appErr.Id)
assert.Equal(t, http.StatusServiceUnavailable, appErr.StatusCode)
// The store row must still have the original message.
fetched, storeErr := th.App.Srv().Store().ScheduledPost().Get(saved.Id)
require.NoError(t, storeErr)
assert.Equal(t, "original", fetched.Message, "rejected update must not be persisted")
})
// State 3 (update): unguarded channel — UpdateScheduledPost passes through
t.Run("update passes on unguarded channel", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
sp := &model.ScheduledPost{
Draft: model.Draft{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "original",
},
ScheduledAt: model.GetMillis() + 60_000,
}
saved, appErr := th.App.SaveScheduledPost(th.Context, sp, "")
require.Nil(t, appErr)
require.NotNil(t, saved)
saved.Message = "updated"
updated, appErr := th.App.UpdateScheduledPost(th.Context, th.BasicUser.Id, saved, "")
require.Nil(t, appErr)
require.NotNil(t, updated)
assert.Equal(t, "updated", updated.Message)
// Confirm the updated message is actually in the store.
fetched, storeErr := th.App.Srv().Store().ScheduledPost().Get(saved.Id)
require.NoError(t, storeErr)
assert.Equal(t, "updated", fetched.Message)
})
}
// TestChannelGuardBlocksDraftWhenPluginInactive verifies fail-closed enforcement on the
// UpsertDraft path. Three states are exercised:
// (1) plugin active + hook rejects → 400 rejected_by_plugin, no row persisted
// (2) plugin inactive after guard registration → 503 inactive_guard, no row persisted
// (3) unguarded channel → passes through, row persists normally
func TestChannelGuardBlocksDraftWhenPluginInactive(t *testing.T) {
mainHelper.Parallel(t)
// State 1: plugin active and hook rejects.
// Guard registered via App method so the plugin process stays alive for the hook call.
t.Run("rejected by active plugin", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
defer enableDrafts(th)()
tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t,
[]string{guardPluginRejectsAll("draft not permitted")},
th.App, th.NewPluginAPI,
)
defer tearDown()
require.NoError(t, errs[0])
pluginID := pluginIDs[0]
require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID))
appErr := th.App.RegisterChannelGuard(th.Context, th.BasicChannel.Id, pluginID)
require.Nil(t, appErr, "RegisterChannelGuard must succeed")
draft := &model.Draft{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "should be rejected",
}
_, appErr = th.App.UpsertDraft(th.Context, draft, "")
require.NotNil(t, appErr)
assert.Contains(t, appErr.Id, "rejected_by_plugin")
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
drafts, getErr := th.App.GetDraftsForUser(th.Context, th.BasicUser.Id, th.BasicTeam.Id)
require.Nil(t, getErr)
assert.Empty(t, drafts)
})
// State 2: plugin inactive — UpsertDraft must return 503 and write no row.
// Guard registered in OnActivate; the row persists after deactivation.
t.Run("blocked when guard plugin inactive", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
defer enableDrafts(th)()
tearDown, pluginIDs, errs := SetAppEnvironmentWithPlugins(t,
[]string{guardPluginRegistersOnly(th.BasicChannel.Id)},
th.App, th.NewPluginAPI,
)
defer tearDown()
require.NoError(t, errs[0])
pluginID := pluginIDs[0]
require.True(t, th.App.GetPluginsEnvironment().IsActive(pluginID))
require.True(t, th.App.GetPluginsEnvironment().Deactivate(pluginID))
require.False(t, th.App.GetPluginsEnvironment().IsActive(pluginID))
draft := &model.Draft{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "plaintext must not persist",
}
_, appErr := th.App.UpsertDraft(th.Context, draft, "")
require.NotNil(t, appErr)
assert.Equal(t, "app.plugin.inactive_guard.app_error", appErr.Id)
assert.Equal(t, http.StatusServiceUnavailable, appErr.StatusCode)
// No draft row may exist for this channel/user.
drafts, getErr := th.App.GetDraftsForUser(th.Context, th.BasicUser.Id, th.BasicTeam.Id)
require.Nil(t, getErr)
for _, d := range drafts {
assert.NotEqual(t, th.BasicChannel.Id, d.ChannelId, "rejected draft must not be in the store")
}
})
// State 3: unguarded channel — UpsertDraft passes through, row persists
t.Run("passes on unguarded channel", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
defer enableDrafts(th)()
// No plugin loaded → no guard row → plain pass-through.
draft := &model.Draft{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Message: "unguarded, should persist",
}
saved, appErr := th.App.UpsertDraft(th.Context, draft, "")
require.Nil(t, appErr)
require.NotNil(t, saved)
// Confirm the row actually exists in the store with the expected content.
drafts, getErr := th.App.GetDraftsForUser(th.Context, th.BasicUser.Id, th.BasicTeam.Id)
require.Nil(t, getErr)
require.NotEmpty(t, drafts)
var found bool
for _, d := range drafts {
if d.ChannelId == th.BasicChannel.Id {
assert.Equal(t, "unguarded, should persist", d.Message)
found = true
}
}
assert.True(t, found, "draft must exist in the store for the expected channel")
})
}