mattermost/server/channels/app/plugin_requests_test.go
Pavel Zeman 6fdef8c9cc
ci: enable fullyparallel mode for server tests (#35816)
* ci: enable fullyparallel mode for server tests

Replace os.Setenv, os.Chdir, and global state mutations with
parallel-safe alternatives (t.Setenv, t.Chdir, test hooks) across
37 files. Refactor GetLogRootPath and MM_INSTALL_TYPE to use
package-level test hooks instead of environment variables.

This enables gotestsum --fullparallel, allowing all test packages
to run with maximum parallelism within each shard.

Co-authored-by: Claude <claude@anthropic.com>

* ci: split fullyparallel from continue-on-error in workflow template

- Add new boolean input 'allow-failure' separate from 'fullyparallel'
- Change continue-on-error to use allow-failure instead of fullyparallel
- Update server-ci.yml to pass allow-failure: true for test coverage job
- Allows independent control of parallel execution and failure tolerance

Co-authored-by: Claude <claude@anthropic.com>

* fix: protect TestOverrideLogRootPath with sync.Mutex for parallel tests

- Replace global var TestOverrideLogRootPath with mutex-protected functions
- Add SetTestOverrideLogRootPath() and getTestOverrideLogRootPath() functions
- Update GetLogRootPath() to use thread-safe getter
- Update all test files to use SetTestOverrideLogRootPath() with t.Cleanup()
- Fixes race condition when running tests with t.Parallel()

Co-authored-by: Claude <claude@anthropic.com>

* fix: configure audit settings before server setup in tests

- Move ExperimentalAuditSettings from UpdateConfig() to config defaults
- Pass audit config via app.Config() option in SetupWithServerOptions()
- Fixes audit test setup ordering to configure BEFORE server initialization
- Resolves CodeRabbit's audit config timing issue in api4 tests

Co-authored-by: Claude <claude@anthropic.com>

* fix: implement SetTestOverrideLogRootPath mutex in logger.go

The previous commit updated test callers to use SetTestOverrideLogRootPath()
but didn't actually create the function in config/logger.go, causing build
failures across all CI shards. This commit:

- Replaces the exported var TestOverrideLogRootPath with mutex-protected
  unexported state (testOverrideLogRootPath + testOverrideLogRootMu)
- Adds exported SetTestOverrideLogRootPath() setter
- Adds unexported getTestOverrideLogRootPath() getter
- Updates GetLogRootPath() to use the thread-safe getter
- Fixes log_test.go callers that were missed in the previous commit

Co-authored-by: Claude <claude@anthropic.com>

* fix(test): use SetupConfig for access_control feature flag registration

InitAccessControlPolicy() checks FeatureFlags.AttributeBasedAccessControl
at route registration time during server startup. Setting the flag via
UpdateConfig after Setup() is too late — routes are never registered
and API calls return 404.

Use SetupConfig() to pass the feature flag in the initial config before
server startup, ensuring routes are properly registered.

Co-authored-by: Claude <claude@anthropic.com>

* fix(test): restore BurnOnRead flag state in TestRevealPost subtest

The 'feature not enabled' subtest disables BurnOnRead without restoring
it via t.Cleanup. Subsequent subtests inherit the disabled state, which
can cause 501 errors when they expect the feature to be available.

Add t.Cleanup to restore FeatureFlags.BurnOnRead = true after the
subtest completes.

Co-authored-by: Claude <claude@anthropic.com>

* fix(test): restore EnableSharedChannelsMemberSync flag via t.Cleanup

The test disables EnableSharedChannelsMemberSync without restoring it.
If the subtest exits early (e.g., require failure), later sibling
subtests inherit a disabled flag and become flaky.

Add t.Cleanup to restore the flag after the subtest completes.

Co-authored-by: Claude <claude@anthropic.com>

* Fix test parallelism: use instance-scoped overrides and init-time audit config

  Replace package-level test globals (TestOverrideInstallType,
  SetTestOverrideLogRootPath) with fields on PlatformService so each test
  gets its own instance without process-wide mutation. Fix three audit
  tests (TestUserLoginAudit, TestLogoutAuditAuthStatus,
  TestUpdatePasswordAudit) that configured the audit logger after server
  init — the audit logger only reads config at startup, so pass audit
  settings via app.Config() at init time instead.

  Also revert the Go 1.24.13 downgrade and bump mattermost-govet to
  v2.0.2 for Go 1.25.8 compatibility.

* Fix audit unit tests

* Fix MMCLOUDURL unit tests

* Fixed unit tests using MM_NOTIFY_ADMIN_COOL_OFF_DAYS

* Make app migrations idempotent for parallel test safety

  Change System().Save() to System().SaveOrUpdate() in all migration
  completion markers. When two parallel tests share a database pool entry,
  both may race through the check-then-insert migration pattern. Save()
  causes a duplicate key fatal crash; SaveOrUpdate() makes the second
  write a harmless no-op.

* test: address review feedback on fullyparallel PR

- Use SetLogRootPathOverride() setter instead of direct field access
  in platform/support_packet_test.go and platform/log_test.go (pvev)
- Restore TestGetLogRootPath in config/logger_test.go to keep
  MM_LOG_PATH env var coverage; test uses t.Setenv so it runs
  serially which is fine (pvev)
- Fix misleading comment in config_test.go: code uses t.Setenv,
  not os.Setenv (jgheithcock)

Co-authored-by: Claude <claude@anthropic.com>

* fix: add missing os import in post_test.go

The os import was dropped during a merge conflict resolution while
burn-on-read shared channel tests from master still use os.Setenv.

Co-authored-by: Claude <claude@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: wiggin77 <wiggin77@warpmail.net>
Co-authored-by: Mattermost Build <build@mattermost.com>
2026-04-08 20:48:36 -04:00

615 lines
22 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
)
func TestServePluginPublicRequest(t *testing.T) {
installPlugin := func(t *testing.T, th *TestHelper, pluginID string) {
t.Helper()
path, _ := fileutils.FindDir("tests")
fileReader, err := os.Open(filepath.Join(path, fmt.Sprintf("%s.tar.gz", pluginID)))
require.NoError(t, err)
defer fileReader.Close()
_, appErr := th.App.WriteFile(fileReader, getBundleStorePath(pluginID))
checkNoError(t, appErr)
appErr = th.App.SyncPlugins()
checkNoError(t, appErr)
env := th.App.GetPluginsEnvironment()
require.NotNil(t, env)
// Check if installed
pluginStatus, err := env.Statuses()
require.NoError(t, err)
found := false
for _, pluginStatus := range pluginStatus {
if pluginStatus.PluginId == pluginID {
found = true
}
}
require.True(t, found, "failed to find plugin %s in plugin statuses", pluginID)
appErr = th.App.EnablePlugin(pluginID)
checkNoError(t, appErr)
t.Cleanup(func() {
appErr = th.App.ch.RemovePlugin(pluginID)
checkNoError(t, appErr)
})
}
t.Run("returns not found when plugins environment is nil", func(t *testing.T) {
th := Setup(t)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = true })
req, err := http.NewRequest(http.MethodGet, "/plugins/plugin_id/public/file.txt", nil)
require.NoError(t, err)
rr := httptest.NewRecorder()
handler := http.HandlerFunc(th.App.ch.ServePluginPublicRequest)
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusNotFound, rr.Code)
})
t.Run("resolves path for valid plugin", func(t *testing.T) {
th := Setup(t)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = true })
path, _ := fileutils.FindDir("tests")
fileReader, err := os.Open(filepath.Join(path, "testplugin.tar.gz"))
require.NoError(t, err)
defer fileReader.Close()
installPlugin(t, th, "testplugin")
req, err := http.NewRequest(http.MethodGet, "/plugins/testplugin/public/file.txt", nil)
require.NoError(t, err)
rr := httptest.NewRecorder()
th.App.ch.srv.Router.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
body, err := io.ReadAll(rr.Body)
require.NoError(t, err)
require.Equal(t, "Hello World!", string(body))
})
t.Run("resolves path for valid plugin when subpath configured", func(t *testing.T) {
// SiteURL must be set before server init so the Router is created with the correct subpath prefix.
// Using UpdateConfig after Setup would not rebuild the Router.
th := SetupConfig(t, func(cfg *model.Config) {
cfg.ServiceSettings.SiteURL = model.NewPointer("http://localhost:8065/subpath")
})
installPlugin(t, th, "testplugin")
req, err := http.NewRequest(http.MethodGet, "/subpath/plugins/testplugin/public/file.txt", nil)
require.NoError(t, err)
rr := httptest.NewRecorder()
th.App.ch.srv.RootRouter.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
body, err := io.ReadAll(rr.Body)
require.NoError(t, err)
assert.Equal(t, "Hello World!", string(body))
})
t.Run("fails for invalid plugin", func(t *testing.T) {
th := Setup(t)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = true })
req, err := http.NewRequest(http.MethodGet, "/plugins/invalidplugin/public/file.txt", nil)
require.NoError(t, err)
rr := httptest.NewRecorder()
th.App.ch.srv.Router.ServeHTTP(rr, req)
assert.Equal(t, http.StatusNotFound, rr.Code)
})
t.Run("fails attempting to break out of path", func(t *testing.T) {
// SiteURL must be set before server init so the Router is created with the correct subpath prefix.
// Using UpdateConfig after Setup would not rebuild the Router.
th := SetupConfig(t, func(cfg *model.Config) {
cfg.ServiceSettings.SiteURL = model.NewPointer("http://localhost:8065/subpath")
})
installPlugin(t, th, "testplugin")
installPlugin(t, th, "testplugin2")
req, err := http.NewRequest(http.MethodGet, "/subpath/plugins/testplugin/public/../../testplugin2/file.txt", nil)
require.NoError(t, err)
rr := httptest.NewRecorder()
th.App.ch.srv.RootRouter.ServeHTTP(rr, req)
require.Equal(t, http.StatusMovedPermanently, rr.Code)
assert.Equal(t, "/subpath/plugins/testplugin2/file.txt", rr.Header()["Location"][0])
})
}
// TestUnauthRequestsMFAWarningFix tests the fix for https://mattermost.atlassian.net/browse/MM-63805.
func TestUnauthRequestsMFAWarningFix(t *testing.T) {
th := Setup(t)
// Enable MFA and require it
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableMultifactorAuthentication = true
*cfg.ServiceSettings.EnforceMultifactorAuthentication = true
})
th.App.Srv().SetLicense(model.NewTestLicense())
// Setup a buffer to capture logs
buffer := &mlog.Buffer{}
err := mlog.AddWriterTarget(th.TestLogger, buffer, true, mlog.StdAll...)
require.NoError(t, err)
// Test the fix by simulating an unauthenticated request (no token at all)
unauthReq := httptest.NewRequest(http.MethodGet, "/plugins/foo/bar", nil)
unauthReq = mux.SetURLVars(unauthReq, map[string]string{"plugin_id": "foo"})
// Handler function for the plugin request
handlerCalled := false
handler := func(_ *plugin.Context, _ http.ResponseWriter, r *http.Request) {
handlerCalled = true
// Verify URL path was properly stripped
require.Equal(t, "/bar", r.URL.Path)
// Verify no user ID header (indicating the request is unauthenticated)
require.Empty(t, r.Header.Get("Mattermost-User-Id"))
}
// Call servePluginRequest directly
th.App.ch.servePluginRequest(nil, unauthReq, handler)
// Verify the handler was actually called
require.True(t, handlerCalled, "Plugin request handler should be called")
// Check the logs for the MFA warning
err = th.TestLogger.Flush()
require.NoError(t, err)
entries := testlib.ParseLogEntries(t, buffer)
for _, e := range entries {
if e.Msg == "Treating session as unauthenticated since MFA required" {
assert.Fail(t, "MFA warning should not be logged for unauthenticated requests")
}
if e.Msg == "Token in plugin request is invalid. Treating request as unauthenticated" {
assert.Fail(t, "MFA warning should not be logged for unauthenticated requests")
}
}
}
func TestServePluginRequest(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
session, err := th.App.CreateSession(th.Context, &model.Session{
UserId: th.BasicUser.Id,
})
require.Nil(t, err)
t.Run("Plugins are disabled", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = false })
t.Cleanup(func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = true })
})
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/plugins/foo/bar", nil)
th.App.ch.ServePluginRequest(w, r)
assert.Equal(t, http.StatusNotImplemented, w.Result().StatusCode)
})
t.Run("unauthenticated request", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
rr := httptest.NewRecorder()
handlerCalled := false
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
handlerCalled = true
assert.Empty(t, r.Header.Get("Mattermost-User-Id"))
assert.Empty(t, ctx.SessionId)
assert.NotEmpty(t, ctx.RequestId)
}
th.App.ch.servePluginRequest(rr, req, mockHandler)
require.True(t, handlerCalled)
})
t.Run("bearer token authentication", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
req.Header.Set(model.HeaderAuth, model.HeaderBearer+" "+session.Token)
rr := httptest.NewRecorder()
handlerCalled := false
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
handlerCalled = true
assert.Equal(t, th.BasicUser.Id, r.Header.Get("Mattermost-User-Id"))
assert.Equal(t, session.Id, ctx.SessionId)
}
th.App.ch.servePluginRequest(rr, req, mockHandler)
require.True(t, handlerCalled)
// Test again with lower case header prefix
handlerCalled = false
req.Header.Set(model.HeaderAuth, "bearer "+session.Token)
th.App.ch.servePluginRequest(rr, req, mockHandler)
require.True(t, handlerCalled)
})
t.Run("token header authentication", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
req.Header.Set(model.HeaderAuth, model.HeaderToken+" "+session.Token)
rr := httptest.NewRecorder()
handlerCalled := false
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
handlerCalled = true
assert.Equal(t, th.BasicUser.Id, r.Header.Get("Mattermost-User-Id"))
assert.Equal(t, session.Id, ctx.SessionId)
}
th.App.ch.servePluginRequest(rr, req, mockHandler)
require.True(t, handlerCalled)
// Test again with upper case header prefix
handlerCalled = false
req.Header.Set(model.HeaderAuth, "TOKEN "+session.Token)
th.App.ch.servePluginRequest(rr, req, mockHandler)
require.True(t, handlerCalled)
})
t.Run("cookie authentication", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
req.AddCookie(&http.Cookie{
Name: model.SessionCookieToken,
Value: session.Token,
})
rr := httptest.NewRecorder()
handlerCalled := false
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
handlerCalled = true
assert.Equal(t, th.BasicUser.Id, r.Header.Get("Mattermost-User-Id"))
assert.Equal(t, session.Id, ctx.SessionId)
}
th.App.ch.servePluginRequest(rr, req, mockHandler)
require.True(t, handlerCalled)
})
t.Run("query parameter authentication", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint?access_token="+session.Token, nil)
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
rr := httptest.NewRecorder()
handlerCalled := false
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
handlerCalled = true
assert.Equal(t, th.BasicUser.Id, r.Header.Get("Mattermost-User-Id"))
assert.Equal(t, session.Id, ctx.SessionId)
// Verify access_token is removed from query parameters
assert.Empty(t, r.URL.Query().Get("access_token"))
}
th.App.ch.servePluginRequest(rr, req, mockHandler)
require.True(t, handlerCalled)
})
t.Run("invalid token - treats as unauthenticated", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
req.Header.Set(model.HeaderAuth, model.HeaderBearer+" invalidtoken")
rr := httptest.NewRecorder()
handlerCalled := false
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
handlerCalled = true
assert.Empty(t, r.Header.Get("Mattermost-User-Id"))
assert.Empty(t, ctx.SessionId)
}
th.App.ch.servePluginRequest(rr, req, mockHandler)
require.True(t, handlerCalled)
})
t.Run("MFA required - treats as unauthenticated", func(t *testing.T) {
// Enable MFA requirement
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableMultifactorAuthentication = true
*cfg.ServiceSettings.EnforceMultifactorAuthentication = true
})
th.App.Srv().SetLicense(model.NewTestLicense())
t.Cleanup(func() {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableMultifactorAuthentication = false
*cfg.ServiceSettings.EnforceMultifactorAuthentication = false
})
})
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
req.Header.Set(model.HeaderAuth, model.HeaderBearer+" "+session.Token)
rr := httptest.NewRecorder()
handlerCalled := false
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
handlerCalled = true
assert.Empty(t, r.Header.Get("Mattermost-User-Id"))
assert.Empty(t, ctx.SessionId)
}
th.App.ch.servePluginRequest(rr, req, mockHandler)
require.True(t, handlerCalled)
})
t.Run("header and cookie cleanup", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
req.Header.Set(model.HeaderAuth, model.HeaderBearer+" "+session.Token)
req.Header.Set("Mattermost-Plugin-ID", "evil-plugin")
req.Header.Set("Mattermost-User-Id", "evil-user")
req.AddCookie(&http.Cookie{Name: "other_cookie", Value: "keep_me"})
req.AddCookie(&http.Cookie{Name: "another_cookie", Value: "keep_me_too"})
req.Header.Set("Referer", "https://evil.com")
rr := httptest.NewRecorder()
handlerCalled := false
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
handlerCalled = true
assert.Equal(t, th.BasicUser.Id, r.Header.Get("Mattermost-User-Id"))
assert.Equal(t, session.Id, ctx.SessionId)
assert.Empty(t, r.Header.Get("Mattermost-Plugin-ID"))
assert.Empty(t, r.Header.Get(model.HeaderAuth))
assert.Empty(t, r.Header.Get("Referer"))
// Verify that legitimate cookies are preserved (but not session cookies)
cookies := r.Cookies()
cookieNames := make([]string, len(cookies))
for i, cookie := range cookies {
cookieNames[i] = cookie.Name
}
// Session token cookie should be filtered out
assert.NotContains(t, cookieNames, model.SessionCookieToken)
// Other cookies should remain
assert.Contains(t, cookieNames, "other_cookie")
assert.Contains(t, cookieNames, "another_cookie")
}
th.App.ch.servePluginRequest(rr, req, mockHandler)
require.True(t, handlerCalled)
})
t.Run("nested URL path", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/some/deep/path", nil)
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
rr := httptest.NewRecorder()
handlerCalled := false
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
handlerCalled = true
// Path should be stripped of the plugin prefix
assert.Equal(t, "/some/deep/path", r.URL.Path)
}
th.App.ch.servePluginRequest(rr, req, mockHandler)
require.True(t, handlerCalled)
})
t.Run("context creation with correct fields", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/plugins/testplugin/endpoint", nil)
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
req.Header.Set(model.HeaderAuth, model.HeaderBearer+" "+session.Token)
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
req.Header.Set("User-Agent", "TestAgent/1.0")
rr := httptest.NewRecorder()
handlerCalled := false
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
handlerCalled = true
assert.NotEmpty(t, ctx.RequestId)
assert.NotEmpty(t, ctx.IPAddress)
assert.Equal(t, "en-US,en;q=0.9", ctx.AcceptLanguage)
assert.Equal(t, "TestAgent/1.0", ctx.UserAgent)
assert.Equal(t, th.BasicUser.Id, r.Header.Get("Mattermost-User-Id"))
assert.Equal(t, session.Id, ctx.SessionId)
}
th.App.ch.servePluginRequest(rr, req, mockHandler)
require.True(t, handlerCalled)
})
t.Run("subpath handling", func(t *testing.T) {
// Set up with subpath
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065/subpath" })
t.Cleanup(func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065" })
})
req := httptest.NewRequest(http.MethodGet, "/subpath/plugins/testplugin/endpoint", nil)
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
rr := httptest.NewRecorder()
handlerCalled := false
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
handlerCalled = true
// Path should be stripped of both subpath and plugin prefix
assert.Equal(t, "/endpoint", r.URL.Path)
}
th.App.ch.servePluginRequest(rr, req, mockHandler)
require.True(t, handlerCalled)
})
t.Run("CSRF validation for cookie auth POST request", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/plugins/testplugin/endpoint", nil)
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
req.AddCookie(&http.Cookie{
Name: model.SessionCookieToken,
Value: session.Token,
})
req.Header.Set(model.HeaderCsrfToken, session.GetCSRF())
rr := httptest.NewRecorder()
handlerCalled := false
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
handlerCalled = true
assert.Equal(t, th.BasicUser.Id, r.Header.Get("Mattermost-User-Id"))
assert.Equal(t, session.Id, ctx.SessionId)
}
th.App.ch.servePluginRequest(rr, req, mockHandler)
require.True(t, handlerCalled)
})
t.Run("CSRF validation fails for cookie auth POST request", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/plugins/testplugin/endpoint", nil)
req = mux.SetURLVars(req, map[string]string{"plugin_id": "testplugin"})
req.AddCookie(&http.Cookie{
Name: model.SessionCookieToken,
Value: session.Token,
})
req.Header.Set(model.HeaderCsrfToken, "invalid-csrf-token")
rr := httptest.NewRecorder()
handlerCalled := false
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
handlerCalled = true
// Should not have user ID header due to CSRF failure
assert.Empty(t, r.Header.Get("Mattermost-User-Id"))
assert.Empty(t, ctx.SessionId)
}
th.App.ch.servePluginRequest(rr, req, mockHandler)
require.True(t, handlerCalled)
})
t.Run("third-party use of Authorization header preserved", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/plugins/testplugin/endpoint", nil)
req.Header.Set(model.HeaderAuth, "Bearer 3rd-party-token")
rr := httptest.NewRecorder()
handlerCalled := false
mockHandler := func(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
handlerCalled = true
// Should still have the authorization header
assert.Equal(t, "Bearer 3rd-party-token", r.Header.Get(model.HeaderAuth))
}
th.App.ch.servePluginRequest(rr, req, mockHandler)
require.True(t, handlerCalled)
})
}
func TestValidateCSRFForPluginRequest(t *testing.T) {
th := Setup(t)
t.Run("skip CSRF for non-cookie auth", func(t *testing.T) {
session := &model.Session{Id: "sessionid", UserId: "userid", Token: "token"}
session.GenerateCSRF()
req := httptest.NewRequest(http.MethodPost, "/test", nil)
result := validateCSRFForPluginRequest(th.Context, req, session, false, false)
assert.True(t, result)
})
t.Run("skip CSRF for GET requests", func(t *testing.T) {
session := &model.Session{Id: "sessionid", UserId: "userid", Token: "token"}
session.GenerateCSRF()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
result := validateCSRFForPluginRequest(th.Context, req, session, true, false)
assert.True(t, result)
})
t.Run("valid CSRF token in header", func(t *testing.T) {
session := &model.Session{Id: "sessionid", UserId: "userid", Token: "token"}
expectedToken := session.GenerateCSRF()
req := httptest.NewRequest(http.MethodPost, "/test", nil)
req.Header.Set(model.HeaderCsrfToken, expectedToken)
result := validateCSRFForPluginRequest(th.Context, req, session, true, false)
assert.True(t, result)
})
t.Run("invalid CSRF token in header", func(t *testing.T) {
session := &model.Session{Id: "sessionid", UserId: "userid", Token: "token"}
session.GenerateCSRF()
req := httptest.NewRequest(http.MethodPost, "/test", nil)
req.Header.Set(model.HeaderCsrfToken, "invalid-token")
result := validateCSRFForPluginRequest(th.Context, req, session, true, false)
assert.False(t, result)
})
t.Run("valid CSRF token in form data", func(t *testing.T) {
session := &model.Session{Id: "sessionid", UserId: "userid", Token: "token"}
expectedToken := session.GetCSRF()
formData := "csrf=" + expectedToken + "&other=value"
req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(formData))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
result := validateCSRFForPluginRequest(th.Context, req, session, true, false)
assert.True(t, result)
})
t.Run("XMLHttpRequest with strict enforcement disabled", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.ExperimentalStrictCSRFEnforcement = false
})
session := &model.Session{Id: "sessionid", UserId: "userid", Token: "token"}
session.GenerateCSRF()
req := httptest.NewRequest(http.MethodPost, "/test", nil)
req.Header.Set(model.HeaderRequestedWith, model.HeaderRequestedWithXML)
result := validateCSRFForPluginRequest(th.Context, req, session, true, false)
assert.True(t, result)
})
t.Run("XMLHttpRequest with strict enforcement enabled", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.ExperimentalStrictCSRFEnforcement = true
})
session := &model.Session{Id: "sessionid", UserId: "userid", Token: "token"}
session.GenerateCSRF()
req := httptest.NewRequest(http.MethodPost, "/test", nil)
req.Header.Set(model.HeaderRequestedWith, model.HeaderRequestedWithXML)
result := validateCSRFForPluginRequest(th.Context, req, session, true, true)
assert.False(t, result)
})
}