mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
The server module named github.com/hanzoai/team/server/v8 — a path that resolves to the retired workspace's repository, not this one — while server/public still published github.com/mattermost/mattermost/server/public. Both are github.com/hanzoteam/server/... now, which is where this code actually lives. That second half is what made the binary unbootable. Upstream's public path stayed claimed by upstream, mattermost-plugin-ai imported it, and the build linked public/model twice; public/plugin's gob.Register then ran twice under one name and panicked at init, before main. Reverting the rename traded the brand for a working binary. Rebranding the plugin alongside it needs no such trade: the agents fork is github.com/hanzoteam/agents/v2 against our public module, so no module named mattermost/mattermost is in the graph at all. Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and it is ours.
626 lines
23 KiB
Go
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/hanzoteam/server/server/public/model"
|
|
"github.com/hanzoteam/server/server/public/shared/request"
|
|
"github.com/hanzoteam/server/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/hanzoteam/server/server/public/plugin"
|
|
"github.com/hanzoteam/server/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/hanzoteam/server/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")
|
|
})
|
|
}
|