Files
zeekay 8feb24ea1f One name for this fork, and it is ours
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.
2026-08-28 23:35:36 -07:00

467 lines
15 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package filestore
import (
"bytes"
"context"
"errors"
"io"
"net"
"net/http"
"net/url"
"os"
"testing"
"time"
"github.com/hanzoteam/server/server/public/model"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
func TestBuildAzureServiceURL(t *testing.T) {
const account = "acmemattermost"
tests := []struct {
name string
cloud string
scheme string
account string // defaults to the package-level account const when empty
endpoint string
expected string
wantErr bool
}{
{
name: "commercial cloud, default scheme",
cloud: model.AzureCloudCommercial,
scheme: "https",
expected: "https://acmemattermost.blob.core.windows.net/",
},
{
name: "commercial cloud rejects an account name with a hash character",
cloud: model.AzureCloudCommercial,
scheme: "https",
account: "account#",
wantErr: true,
},
{
name: "commercial cloud rejects an account name with a slash character",
cloud: model.AzureCloudCommercial,
scheme: "https",
account: "account/",
wantErr: true,
},
{
name: "government cloud rejects a malformed account name",
cloud: model.AzureCloudGovernment,
scheme: "https",
account: "account#",
wantErr: true,
},
{
name: "empty cloud falls back to commercial for legacy configs",
cloud: "",
scheme: "https",
expected: "https://acmemattermost.blob.core.windows.net/",
},
{
name: "government cloud uses the usgovcloudapi suffix",
cloud: model.AzureCloudGovernment,
scheme: "https",
expected: "https://acmemattermost.blob.core.usgovcloudapi.net/",
},
{
name: "custom cloud returns the endpoint verbatim (vhost-style)",
cloud: model.AzureCloudCustom,
scheme: "https",
endpoint: "https://acmemattermost.blob.core.windows.net/",
expected: "https://acmemattermost.blob.core.windows.net/",
},
{
name: "custom cloud returns the endpoint verbatim (Azurite path-style)",
cloud: model.AzureCloudCustom,
scheme: "http",
endpoint: "http://localhost:10000/devstoreaccount1/",
expected: "http://localhost:10000/devstoreaccount1/",
},
{
name: "custom cloud preserves arbitrary paths the admin provides",
cloud: model.AzureCloudCustom,
scheme: "https",
endpoint: "https://blob-proxy.internal.example.com/some/prefix/account/",
expected: "https://blob-proxy.internal.example.com/some/prefix/account/",
},
{
name: "custom cloud rejects an empty endpoint",
cloud: model.AzureCloudCustom,
scheme: "https",
wantErr: true,
},
{
name: "custom cloud rejects an endpoint missing the scheme",
cloud: model.AzureCloudCustom,
scheme: "https",
endpoint: "acmemattermost.blob.core.windows.net/",
wantErr: true,
},
{
name: "custom cloud rejects an endpoint with no host",
cloud: model.AzureCloudCustom,
scheme: "https",
endpoint: "https:///acmemattermost/",
wantErr: true,
},
{
name: "custom cloud rejects a non-HTTP scheme",
cloud: model.AzureCloudCustom,
scheme: "https",
endpoint: "ftp://blob.example.com/",
wantErr: true,
},
{
name: "unknown cloud value is rejected",
cloud: "azuregermany",
scheme: "https",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
acct := account
if tt.account != "" {
acct = tt.account
}
got, err := buildAzureServiceURL(tt.cloud, tt.scheme, acct, tt.endpoint)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.expected, got)
})
}
}
func TestAzureFileBackendPrefix(t *testing.T) {
tests := []struct {
name string
prefix string
input string
expected string
}{
{name: "no prefix, plain path", prefix: "", input: "team/channel/file", expected: "team/channel/file"},
{name: "no prefix, with dot-dot", prefix: "", input: "../escape", expected: "../escape"},
{name: "prefix, plain path", prefix: "mattermost", input: "team/channel/file", expected: "mattermost/team/channel/file"},
{name: "prefix, exact root", prefix: "mattermost", input: "", expected: "mattermost"},
{name: "prefix, dot-dot escapes", prefix: "mattermost", input: "../escape", expected: "mattermost/escape"},
{name: "prefix, nested dot-dot escapes", prefix: "mattermost", input: "sub/../../escape", expected: "mattermost/escape"},
{name: "prefix, dot-dot in middle stays inside", prefix: "mattermost", input: "a/../b", expected: "mattermost/b"},
{name: "prefix with trailing slash, dot-dot escapes", prefix: "mattermost/", input: "../escape", expected: "mattermost/escape"},
{name: "prefix boundary collision must not escape", prefix: "mattermost", input: "../mattermost-evil/file", expected: "mattermost/file"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := &AzureFileBackend{pathPrefix: tt.prefix}
require.Equal(t, tt.expected, b.prefix(tt.input))
})
}
}
// azuriteWellKnownAccount and azuriteWellKnownKey are Azurite's published
// development credentials. They are not secrets - they are documented in the
// Azurite README and ship hardcoded in every Azurite distribution.
const (
azuriteWellKnownAccount = "devstoreaccount1"
azuriteWellKnownKey = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=="
)
// TestAzureFileBackendAppendRefusesNonBlockBlob exercises the safety
// check in AppendFile: when a blob exists with content but no committed
// block list (i.e. it was uploaded via Put Blob by another tool), the
// backend must refuse the append rather than silently destroy the
// existing content.
func TestAzureFileBackendAppendRefusesNonBlockBlob(t *testing.T) {
be := newAzuriteBackend(t)
path := "append-refusal-test.bin"
t.Cleanup(func() { _ = be.RemoveFile(path) })
// Write the blob via the high-level Upload helper, which calls the
// Put Blob REST endpoint and leaves the committed-block list empty.
original := []byte("planted-by-another-tool")
bb := be.newBlockBlobClient(path)
_, err := bb.Upload(context.Background(), nopReadSeekCloser{bytes.NewReader(original)}, nil)
require.NoError(t, err)
_, err = be.AppendFile(bytes.NewReader([]byte("would-overwrite")), path)
require.Error(t, err)
require.Contains(t, err.Error(), "no committed block list")
// The original content must still be intact.
got, err := be.ReadFile(path)
require.NoError(t, err)
require.Equal(t, original, got)
}
// TestAzureFileBackendMakeContainerIdempotent ensures that calling
// MakeContainer twice on the same backend is a no-op the second time.
// Two nodes can race through TestConnection plus MakeContainer at boot;
// the loser must converge instead of returning an error.
func TestAzureFileBackendMakeContainerIdempotent(t *testing.T) {
be := newAzuriteBackend(t)
require.NoError(t, be.MakeContainer())
require.NoError(t, be.MakeContainer())
}
type nopReadSeekCloser struct {
*bytes.Reader
}
func (nopReadSeekCloser) Close() error { return nil }
// newAzuriteBackend builds an Azure backend pointed at the Azurite emulator
// and ensures the container exists. Standalone Azure tests should use this
// instead of calling NewAzureFileBackend + TestConnection directly; the
// shared FileBackendTestSuite handles provisioning itself in SetupTest.
func newAzuriteBackend(t *testing.T) *AzureFileBackend {
t.Helper()
be, err := NewAzureFileBackend(azuriteSettings(t))
require.NoError(t, err)
var noBucket *FileBackendNoBucketError
if err := be.TestConnection(); errors.As(err, &noBucket) {
require.NoError(t, be.MakeContainer())
} else {
require.NoError(t, err)
}
return be
}
func azuriteSettings(t *testing.T) FileBackendSettings {
t.Helper()
host := os.Getenv("CI_AZURITE_HOST")
if host == "" {
host = "localhost"
}
port := os.Getenv("CI_AZURITE_PORT")
if port == "" {
port = "10000"
}
return FileBackendSettings{
DriverName: driverAzure,
AzureStorageAccount: azuriteWellKnownAccount,
AzureAuthMode: model.AzureAuthModeSharedKey,
AzureAccessKey: azuriteWellKnownKey,
AzureContainer: "mattermost-test",
AzureCloud: model.AzureCloudCustom,
AzureEndpoint: "http://" + net.JoinHostPort(host, port) + "/" + azuriteWellKnownAccount + "/",
// The emulator runs on an internal address, so its host must be allowed.
AllowedUntrustedInternalConnections: host,
AzureRequestTimeoutMilliseconds: 30000,
}
}
func TestNewAzureFileBackendAuthMode(t *testing.T) {
base := FileBackendSettings{
DriverName: driverAzure,
AzureStorageAccount: "anaccount",
AzureContainer: "acontainer",
AzureEndpoint: "localhost:10000",
AzureSSL: false,
AzureRequestTimeoutMilliseconds: 30000,
}
t.Run("shared_key constructs a client", func(t *testing.T) {
s := base
s.AzureAuthMode = model.AzureAuthModeSharedKey
s.AzureAccessKey = azuriteWellKnownKey
be, err := NewAzureFileBackend(s)
require.NoError(t, err)
require.NotNil(t, be.client)
})
t.Run("default_credential constructs a client without an access key", func(t *testing.T) {
s := base
s.AzureAuthMode = model.AzureAuthModeDefaultCredential
// Intentionally no AzureAccessKey - default credential reads
// identity from the host environment, not config.
be, err := NewAzureFileBackend(s)
require.NoError(t, err)
require.NotNil(t, be.client)
})
t.Run("empty AuthMode is rejected", func(t *testing.T) {
s := base
s.AzureAuthMode = ""
s.AzureAccessKey = azuriteWellKnownKey
_, err := NewAzureFileBackend(s)
require.Error(t, err)
require.Contains(t, err.Error(), "unknown azure auth mode")
})
t.Run("unknown AuthMode is rejected", func(t *testing.T) {
s := base
s.AzureAuthMode = "oauth2"
s.AzureAccessKey = azuriteWellKnownKey
_, err := NewAzureFileBackend(s)
require.Error(t, err)
require.Contains(t, err.Error(), "unknown azure auth mode")
})
}
func TestCheckMandatoryAzureFieldsAuthMode(t *testing.T) {
base := FileBackendSettings{
AzureStorageAccount: "anaccount",
AzureContainer: "acontainer",
}
t.Run("shared_key requires access key", func(t *testing.T) {
s := base
s.AzureAuthMode = model.AzureAuthModeSharedKey
s.AzureAccessKey = ""
require.Error(t, s.CheckMandatoryAzureFields())
s.AzureAccessKey = "somekey"
require.NoError(t, s.CheckMandatoryAzureFields())
})
t.Run("default_credential does not require access key", func(t *testing.T) {
s := base
s.AzureAuthMode = model.AzureAuthModeDefaultCredential
s.AzureAccessKey = ""
require.NoError(t, s.CheckMandatoryAzureFields())
})
}
func TestAzureFileBackendTestSuite(t *testing.T) {
suite.Run(t, &FileBackendTestSuite{settings: azuriteSettings(t)})
}
// TestAzureGeneratePublicLink exercises the Service SAS path against Azurite.
// The user-delegation SAS path requires Entra ID and cannot run in CI; it is
// covered by the manual verification recipe in the PR description.
func TestAzureGeneratePublicLink(t *testing.T) {
t.Run("refuses to issue a link when presign expiration is unset", func(t *testing.T) {
settings := azuriteSettings(t)
// Deliberately leave AzurePresignExpiresSeconds at zero - the
// shape an admin would hit if they enabled the export-direct-
// download feature without ever configuring the field.
be, err := NewAzureFileBackend(settings)
require.NoError(t, err)
_, _, err = be.GeneratePublicLink("any/path")
require.Error(t, err)
require.Contains(t, err.Error(), "presign expiration is not configured")
})
t.Run("shared-key SAS round-trips through Azurite", func(t *testing.T) {
settings := azuriteSettings(t)
settings.AzurePresignExpiresSeconds = 300
be, err := NewAzureFileBackend(settings)
require.NoError(t, err)
var noBucket *FileBackendNoBucketError
if connErr := be.TestConnection(); errors.As(connErr, &noBucket) {
require.NoError(t, be.MakeContainer())
} else {
require.NoError(t, connErr)
}
path := "tests/presign-" + model.NewId() + ".bin"
payload := bytes.Repeat([]byte("mattermost"), 4096) // 40 KiB
_, writeErr := be.WriteFile(bytes.NewReader(payload), path)
require.NoError(t, writeErr)
t.Cleanup(func() { _ = be.RemoveFile(path) })
link, expiry, err := be.GeneratePublicLink(path)
require.NoError(t, err)
require.NotEmpty(t, link)
require.Equal(t, 300*time.Second, expiry)
// SAS query parameters Azurite expects to see.
u, err := url.Parse(link)
require.NoError(t, err)
require.NotEmpty(t, u.Query().Get("sig"))
require.Equal(t, "r", u.Query().Get("sp"))
require.Equal(t, "attachment", u.Query().Get("rscd"))
resp, err := http.Get(link)
require.NoError(t, err)
t.Cleanup(func() { _ = resp.Body.Close() })
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "attachment", resp.Header.Get("Content-Disposition"))
got, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, payload, got)
})
t.Run("tampered SAS is rejected by the storage service", func(t *testing.T) {
settings := azuriteSettings(t)
settings.AzurePresignExpiresSeconds = 300
be, err := NewAzureFileBackend(settings)
require.NoError(t, err)
var noBucket *FileBackendNoBucketError
if connErr := be.TestConnection(); errors.As(connErr, &noBucket) {
require.NoError(t, be.MakeContainer())
} else {
require.NoError(t, connErr)
}
path := "tests/tamper-" + model.NewId() + ".bin"
_, writeErr := be.WriteFile(bytes.NewReader([]byte("contents")), path)
require.NoError(t, writeErr)
t.Cleanup(func() { _ = be.RemoveFile(path) })
link, _, err := be.GeneratePublicLink(path)
require.NoError(t, err)
// Flip the last character of the signature so the link no longer
// matches what Azurite signed.
u, err := url.Parse(link)
require.NoError(t, err)
q := u.Query()
sig := q.Get("sig")
require.NotEmpty(t, sig)
swapped := sig[:len(sig)-1] + flipBase64Char(sig[len(sig)-1])
q.Set("sig", swapped)
u.RawQuery = q.Encode()
resp, err := http.Get(u.String())
require.NoError(t, err)
t.Cleanup(func() { _ = resp.Body.Close() })
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("unknown auth mode is rejected", func(t *testing.T) {
// Build a backend by hand so we can exercise the default-branch
// guard. Going through NewAzureFileBackend would be rejected by
// CheckMandatoryAzureFields first.
be := &AzureFileBackend{
authMode: "totally-not-real",
presignExpires: time.Minute,
}
_, _, err := be.GeneratePublicLink("any/path")
require.Error(t, err)
require.Contains(t, err.Error(), "unknown azure auth mode")
})
}
// flipBase64Char swaps a base64 character for another in the same alphabet
// to produce a still-valid-but-wrong signature character. Used only by the
// tamper test above.
func flipBase64Char(c byte) string {
if c == 'A' {
return "B"
}
return "A"
}