Files
upstream-mattermost/server/public/plugin/context_rpc_test.go
upstream-mirror 240db4e2a7
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:29:55 +05:00

605 lines
17 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"bytes"
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/rpc"
"strings"
"sync"
"testing"
"testing/iotest"
"time"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
const rpcTestTimeout = 10 * time.Second
type blockingReadCloser struct {
closed chan struct{}
}
func (r *blockingReadCloser) Read(p []byte) (int, error) {
<-r.closed
return 0, io.ErrClosedPipe
}
func (r *blockingReadCloser) Close() error {
select {
case <-r.closed:
default:
close(r.closed)
}
return nil
}
func TestRPCRequestContextDeadline(t *testing.T) {
deadline := time.Now().Add(time.Minute).Round(0)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
remoteCtx, remoteCancel := newRPCRequestContext(ctx).Context()
defer remoteCancel()
remoteDeadline, ok := remoteCtx.Deadline()
require.True(t, ok)
require.Equal(t, deadline, remoteDeadline)
}
func TestWatchRPCContextTransportErrorCancels(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go watchRPCContext(ctx, io.NopCloser(iotest.ErrReader(errors.New("transport error"))), cancel)
select {
case <-ctx.Done():
require.ErrorIs(t, ctx.Err(), context.Canceled)
case <-time.After(rpcTestTimeout):
require.FailNow(t, "transport error did not cancel context")
}
}
func TestWatchRPCContextUnknownReasonCancels(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go watchRPCContext(ctx, io.NopCloser(bytes.NewReader([]byte{255})), cancel)
select {
case <-ctx.Done():
require.ErrorIs(t, ctx.Err(), context.Canceled)
case <-time.After(rpcTestTimeout):
require.FailNow(t, "unknown close reason did not cancel context")
}
}
func TestContextReadCloserCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
reader := &blockingReadCloser{closed: make(chan struct{})}
body := newContextReadCloser(ctx, reader, nil)
readDone := make(chan error, 1)
go func() {
_, err := body.Read(make([]byte, 1))
readDone <- err
}()
cancel()
select {
case err := <-readDone:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(rpcTestTimeout):
require.FailNow(t, "read did not stop after cancellation")
}
require.NoError(t, body.Close())
require.NoError(t, body.Close())
}
func TestContextReadCloserCancellationRunsCleanupWithoutReadOrClose(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
reader := &blockingReadCloser{closed: make(chan struct{})}
cleanupDone := make(chan struct{})
body := newContextReadCloser(ctx, reader, func() {
close(cleanupDone)
})
cancel()
select {
case <-cleanupDone:
case <-time.After(rpcTestTimeout):
require.FailNow(t, "cleanup did not run after cancellation")
}
select {
case <-reader.closed:
default:
require.FailNow(t, "reader was not closed after cancellation")
}
require.NoError(t, body.Close())
}
func TestRPCStreamCloserCloseBeforeSet(t *testing.T) {
stream := &rpcStreamCloser{}
require.NoError(t, stream.Close())
closer := &recordingWriteCloser{}
require.False(t, stream.Set(closer))
require.True(t, closer.closed)
require.NoError(t, stream.Close())
}
type recordingWriteCloser struct {
bytes.Buffer
closed bool
}
func (c *recordingWriteCloser) Close() error {
c.closed = true
return nil
}
func TestRPCStreamCloserCancelBeforeSet(t *testing.T) {
testCases := []struct {
name string
err error
reason closeReason
}{
{name: "canceled", err: context.Canceled, reason: rpcStreamCanceled},
{name: "deadline exceeded", err: context.DeadlineExceeded, reason: rpcStreamDeadlineExceeded},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
stream := &rpcStreamCloser{}
require.NoError(t, stream.Cancel(testCase.err))
closer := &recordingWriteCloser{}
require.False(t, stream.Set(closer))
require.Equal(t, []byte{byte(testCase.reason)}, closer.Bytes())
require.True(t, closer.closed)
require.NoError(t, stream.Close())
})
}
}
func TestPluginHTTPAlreadyCanceledClosesRequestBody(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
body := &recordingWriteCloser{}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/plugin/path", body)
require.NoError(t, err)
response := (&apiRPCClient{}).PluginHTTP(request)
require.Nil(t, response)
require.True(t, body.closed)
}
func TestHooksRPCClientServeHTTPAlreadyCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
body := &recordingWriteCloser{}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/plugin/path", body)
require.NoError(t, err)
muxBroker := newTestHooksRPCMuxBroker()
hooks := &hooksRPCClient{contextHTTP: true, muxBroker: muxBroker}
hooks.implemented[ServeHTTPID] = true
hooks.ServeHTTP(&Context{}, httptest.NewRecorder(), request)
require.True(t, body.closed)
require.Zero(t, muxBroker.nextID, "canceled request allocated a mux stream")
}
type blockingResponseWriter struct {
header http.Header
writeStarted chan struct{}
writeRelease chan struct{}
}
func (w *blockingResponseWriter) Header() http.Header {
return w.header
}
func (w *blockingResponseWriter) Write(p []byte) (int, error) {
close(w.writeStarted)
<-w.writeRelease
return len(p), nil
}
func (w *blockingResponseWriter) WriteHeader(statusCode int) {
}
type responseWritingRPCServer struct {
muxBroker rpcMuxBroker
}
func (s *responseWritingRPCServer) ServeHTTP(args *Z_ServeHTTPArgs, returns *struct{}) error {
_, cleanupContext, err := openRPCRequestContext(s.muxBroker, args.RequestContextStream, args.RequestContext)
if err != nil {
return err
}
defer cleanupContext()
connection, err := s.muxBroker.Dial(args.ResponseWriterStream)
if err != nil {
return err
}
responseWriter := connectHTTPResponseWriter(connection)
defer responseWriter.Close()
_, err = responseWriter.Write([]byte("response"))
return err
}
func TestHooksRPCClientServeHTTPCancellationWaitsForResponseWriter(t *testing.T) {
muxBroker := newTestHooksRPCMuxBroker()
serverConnection, clientConnection := net.Pipe()
server := rpc.NewServer()
require.NoError(t, server.RegisterName("Plugin", &responseWritingRPCServer{muxBroker: muxBroker}))
go server.ServeConn(serverConnection)
client := rpc.NewClient(clientConnection)
defer client.Close()
hooks := &hooksRPCClient{client: client, log: mlog.CreateConsoleTestLogger(t), muxBroker: muxBroker, contextHTTP: true}
hooks.implemented[ServeHTTPID] = true
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "/plugin/path", nil)
require.NoError(t, err)
writeStarted := make(chan struct{})
writeRelease := make(chan struct{})
defer close(writeRelease)
writer := &blockingResponseWriter{
header: make(http.Header),
writeStarted: writeStarted,
writeRelease: writeRelease,
}
serveHTTPDone := make(chan struct{})
go func() {
hooks.ServeHTTP(&Context{}, writer, request)
close(serveHTTPDone)
}()
select {
case <-writeStarted:
case <-time.After(rpcTestTimeout):
require.FailNow(t, "response write did not start")
}
cancel()
require.Never(t, func() bool {
select {
case <-serveHTTPDone:
return true
default:
return false
}
}, 100*time.Millisecond, 10*time.Millisecond, "ServeHTTP returned during an in-flight response write")
writeRelease <- struct{}{}
select {
case <-serveHTTPDone:
case <-time.After(rpcTestTimeout):
require.FailNow(t, "ServeHTTP did not return after the response write finished")
}
}
type stalledServeHTTPRPCServer struct {
started chan struct{}
release chan struct{}
}
func (s *stalledServeHTTPRPCServer) ServeHTTP(args *Z_ServeHTTPArgs, returns *struct{}) error {
close(s.started)
<-s.release
return nil
}
type delayedAcceptMuxBroker struct {
release chan struct{}
nextID uint32
}
func (b *delayedAcceptMuxBroker) NextId() uint32 {
b.nextID++
return b.nextID
}
func (b *delayedAcceptMuxBroker) Accept(id uint32) (net.Conn, error) {
<-b.release
hostConnection, pluginConnection := net.Pipe()
_ = pluginConnection.Close()
return hostConnection, nil
}
func (b *delayedAcceptMuxBroker) AcceptAndServe(id uint32, server any) {
}
func (b *delayedAcceptMuxBroker) Dial(id uint32) (net.Conn, error) {
return nil, errors.New("unexpected Dial")
}
func TestHooksRPCClientServeHTTPCancellationBeforeResponseStreamAcceptance(t *testing.T) {
muxBroker := &delayedAcceptMuxBroker{release: make(chan struct{})}
serverConnection, clientConnection := net.Pipe()
server := rpc.NewServer()
serverStarted := make(chan struct{})
serverRelease := make(chan struct{})
defer close(serverRelease)
defer close(muxBroker.release)
require.NoError(t, server.RegisterName("Plugin", &stalledServeHTTPRPCServer{started: serverStarted, release: serverRelease}))
go server.ServeConn(serverConnection)
client := rpc.NewClient(clientConnection)
defer client.Close()
hooks := &hooksRPCClient{client: client, log: mlog.CreateConsoleTestLogger(t), muxBroker: muxBroker, contextHTTP: true}
hooks.implemented[ServeHTTPID] = true
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "/plugin/path", nil)
require.NoError(t, err)
serveHTTPDone := make(chan struct{})
go func() {
hooks.ServeHTTP(&Context{}, httptest.NewRecorder(), request)
close(serveHTTPDone)
}()
select {
case <-serverStarted:
case <-time.After(rpcTestTimeout):
require.FailNow(t, "ServeHTTP RPC did not start")
}
cancel()
select {
case <-serveHTTPDone:
case <-time.After(time.Second):
require.FailNow(t, "ServeHTTP waited for a response stream that was not accepted")
}
}
type legacyRPCServer struct {
muxBroker *testHooksRPCMuxBroker
serveHTTPStarted chan struct{}
serveHTTPRelease chan struct{}
}
type testHooksRPCMuxBroker struct {
nextID uint32
mutex sync.Mutex
streams map[uint32]chan net.Conn
acceptErrors map[uint32]error
}
func newTestHooksRPCMuxBroker() *testHooksRPCMuxBroker {
return &testHooksRPCMuxBroker{streams: make(map[uint32]chan net.Conn)}
}
func (b *testHooksRPCMuxBroker) NextId() uint32 {
b.nextID++
return b.nextID
}
func (b *testHooksRPCMuxBroker) Accept(id uint32) (net.Conn, error) {
if err := b.acceptErrors[id]; err != nil {
return nil, err
}
hostConnection, pluginConnection := net.Pipe()
b.stream(id) <- pluginConnection
return hostConnection, nil
}
func (b *testHooksRPCMuxBroker) AcceptAndServe(id uint32, server any) {
}
func (b *testHooksRPCMuxBroker) Dial(id uint32) (net.Conn, error) {
select {
case connection := <-b.stream(id):
return connection, nil
case <-time.After(rpcTestTimeout):
return nil, errors.New("timeout waiting for stream")
}
}
func (b *testHooksRPCMuxBroker) stream(id uint32) chan net.Conn {
b.mutex.Lock()
defer b.mutex.Unlock()
stream, ok := b.streams[id]
if !ok {
stream = make(chan net.Conn, 1)
b.streams[id] = stream
}
return stream
}
type LegacyOnActivateArgs struct {
APIMuxId uint32
DriverMuxId uint32
}
type LegacyOnActivateReturns struct {
A error
}
type LegacyServeHTTPArgs struct {
ResponseWriterStream uint32
Request *HTTPRequestSubset
Context *Context
RequestBodyStream uint32
}
func (s *legacyRPCServer) OnActivate(args *LegacyOnActivateArgs, returns *LegacyOnActivateReturns) error {
return nil
}
func (s *legacyRPCServer) Ping(args struct{}, reply *bool) error {
*reply = true
return nil
}
func (s *legacyRPCServer) PluginHTTP(args *Z_PluginHTTPArgs, returns *Z_PluginHTTPReturns) error {
returns.Response = &http.Response{StatusCode: http.StatusOK}
returns.ResponseBody = []byte("legacy")
return nil
}
type successfulPluginHTTPStreamRPCServer struct{}
func (s *successfulPluginHTTPStreamRPCServer) PluginHTTPStream(args *Z_PluginHTTPStreamArgs, returns *Z_PluginHTTPStreamReturns) error {
returns.StatusCode = http.StatusOK
return nil
}
func TestPluginHTTPStreamResponseSetupFailureClosesRequestBody(t *testing.T) {
muxBroker := newTestHooksRPCMuxBroker()
muxBroker.acceptErrors = map[uint32]error{1: errors.New("response stream failed")}
serverConnection, clientConnection := net.Pipe()
server := rpc.NewServer()
require.NoError(t, server.RegisterName("Plugin", &successfulPluginHTTPStreamRPCServer{}))
go server.ServeConn(serverConnection)
client := rpc.NewClient(clientConnection)
defer client.Close()
body := &blockingReadCloser{closed: make(chan struct{})}
request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/plugin/path", body)
require.NoError(t, err)
response, err := (&apiRPCClient{client: client, muxBroker: muxBroker}).pluginHTTPStream(request, false)
if response != nil && response.Body != nil {
require.NoError(t, response.Body.Close())
}
require.Nil(t, response)
require.ErrorContains(t, err, "response stream failed")
select {
case <-body.closed:
default:
require.FailNow(t, "request body was not closed after response stream setup failed")
}
requestConnection, err := muxBroker.Dial(2)
require.NoError(t, err)
require.NoError(t, requestConnection.Close())
}
func (s *legacyRPCServer) ServeHTTP(args *LegacyServeHTTPArgs, returns *struct{}) error {
requestConnection, err := s.muxBroker.Dial(args.RequestBodyStream)
if err != nil {
return err
}
requestBody := connectIOReader(requestConnection)
body := make([]byte, len("request"))
_, err = io.ReadFull(requestBody, body)
_ = requestBody.Close()
if err != nil {
return err
}
close(s.serveHTTPStarted)
<-s.serveHTTPRelease
responseConnection, err := s.muxBroker.Dial(args.ResponseWriterStream)
if err != nil {
return err
}
responseWriter := connectHTTPResponseWriter(responseConnection)
defer responseWriter.Close()
responseWriter.Header().Set("X-Legacy-Plugin", "true")
responseWriter.WriteHeader(http.StatusCreated)
_, err = responseWriter.Write(append([]byte("legacy:"), body...))
return err
}
func TestLegacyPluginHTTPFallbackLeavesConnectionUsable(t *testing.T) {
muxBroker := newTestHooksRPCMuxBroker()
serverConnection, clientConnection := net.Pipe()
server := rpc.NewServer()
require.NoError(t, server.RegisterName("Plugin", &legacyRPCServer{}))
go server.ServeConn(serverConnection)
client := rpc.NewClient(clientConnection)
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "/legacy", nil)
require.NoError(t, err)
response := (&apiRPCClient{client: client, muxBroker: muxBroker}).PluginHTTP(request)
require.NotNil(t, response)
body, err := io.ReadAll(response.Body)
require.NoError(t, err)
require.NoError(t, response.Body.Close())
require.Equal(t, http.StatusOK, response.StatusCode)
require.Equal(t, "legacy", string(body))
var pong bool
require.NoError(t, client.Call("Plugin.Ping", struct{}{}, &pong))
require.True(t, pong)
}
func TestLegacyHooksRPCClientServeHTTP(t *testing.T) {
muxBroker := newTestHooksRPCMuxBroker()
serveHTTPStarted := make(chan struct{})
serveHTTPRelease := make(chan struct{})
serverConnection, clientConnection := net.Pipe()
server := rpc.NewServer()
require.NoError(t, server.RegisterName("Plugin", &legacyRPCServer{
muxBroker: muxBroker,
serveHTTPStarted: serveHTTPStarted,
serveHTTPRelease: serveHTTPRelease,
}))
go server.ServeConn(serverConnection)
client := rpc.NewClient(clientConnection)
defer client.Close()
hooks := &hooksRPCClient{client: client, log: mlog.CreateConsoleTestLogger(t), muxBroker: muxBroker}
hooks.implemented[ServeHTTPID] = true
require.NoError(t, hooks.OnActivate())
require.False(t, hooks.contextHTTP)
ctx, cancel := context.WithCancel(context.Background())
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/legacy", strings.NewReader("request"))
require.NoError(t, err)
recorder := httptest.NewRecorder()
serveHTTPDone := make(chan struct{})
go func() {
hooks.ServeHTTP(&Context{RequestId: "legacy"}, recorder, request)
close(serveHTTPDone)
}()
select {
case <-serveHTTPStarted:
case <-time.After(rpcTestTimeout):
require.FailNow(t, "legacy ServeHTTP did not start")
}
cancel()
require.Never(t, func() bool {
select {
case <-serveHTTPDone:
return true
default:
return false
}
}, 100*time.Millisecond, 10*time.Millisecond, "legacy ServeHTTP unexpectedly stopped on context cancellation")
var pong bool
require.NoError(t, client.Call("Plugin.Ping", struct{}{}, &pong))
require.True(t, pong)
close(serveHTTPRelease)
select {
case <-serveHTTPDone:
case <-time.After(rpcTestTimeout):
require.FailNow(t, "legacy ServeHTTP did not finish after release")
}
require.Equal(t, http.StatusCreated, recorder.Code)
require.Equal(t, "true", recorder.Header().Get("X-Legacy-Plugin"))
require.Equal(t, "legacy:request", recorder.Body.String())
}