251 lines
8.4 KiB
Go
251 lines
8.4 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang/mock/gomock"
|
|
"github.com/tinode/chat/server/auth"
|
|
"github.com/tinode/chat/server/auth/mock_auth"
|
|
"github.com/tinode/chat/server/media"
|
|
"github.com/tinode/chat/server/store"
|
|
"github.com/tinode/chat/server/store/mock_store"
|
|
"github.com/tinode/chat/server/store/types"
|
|
)
|
|
|
|
type testLargeFileMediaHandler struct {
|
|
def *types.FileDef
|
|
data string
|
|
fid types.Uid
|
|
headersCalled bool
|
|
corsOrigins []media.AllowedOrigin
|
|
}
|
|
|
|
func (h *testLargeFileMediaHandler) Init(string) error { return nil }
|
|
func (h *testLargeFileMediaHandler) Headers(_ types.TenantID, method string, _ *url.URL, headers http.Header, serve bool) (http.Header, int, error) {
|
|
h.headersCalled = true
|
|
if h.corsOrigins != nil {
|
|
response, status := media.CORSHandler(method, headers, h.corsOrigins, serve)
|
|
return response, status, nil
|
|
}
|
|
return nil, 0, nil
|
|
}
|
|
func (h *testLargeFileMediaHandler) Upload(def *types.FileDef, file io.Reader) (string, int64, error) {
|
|
data, _ := io.ReadAll(file)
|
|
h.def = def
|
|
h.data = string(data)
|
|
def.Location = "/v0/file/s/" + media.FileURLName(def)
|
|
def.ETag = "etag"
|
|
return def.Location, int64(len(data)), nil
|
|
}
|
|
func (h *testLargeFileMediaHandler) Download(types.TenantID, string) (*types.FileDef, media.ReadSeekCloser, error) {
|
|
return nil, nil, types.ErrUnsupported
|
|
}
|
|
func (h *testLargeFileMediaHandler) Delete([]string) error { return nil }
|
|
func (h *testLargeFileMediaHandler) GetIdFromUrl(string) types.Uid {
|
|
return h.fid
|
|
}
|
|
|
|
func TestLargeFileUploadPreflightAllowsPost(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
defer func() { store.Store = oldStore }()
|
|
|
|
origins, err := media.ParseCORSAllow([]string{"https://web1.118im.com"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
handler := &testLargeFileMediaHandler{corsOrigins: origins}
|
|
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
|
mockStore.EXPECT().GetMediaHandler().Return(handler)
|
|
store.Store = mockStore
|
|
|
|
req := httptest.NewRequest(http.MethodOptions, "/v0/file/u/", nil)
|
|
req.Header.Set("Origin", "https://web1.118im.com")
|
|
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
|
req.Header.Set("Access-Control-Request-Headers", "authorization,x-meetingim-apikey")
|
|
wrt := httptest.NewRecorder()
|
|
largeFileReceiveHTTP(wrt, req)
|
|
|
|
if wrt.Code != http.StatusNoContent || !handler.headersCalled {
|
|
t.Fatalf("preflight response: status=%d headers_called=%t", wrt.Code, handler.headersCalled)
|
|
}
|
|
if got := wrt.Header().Get("Access-Control-Allow-Origin"); got != "https://web1.118im.com" {
|
|
t.Fatalf("allowed origin = %q", got)
|
|
}
|
|
if got := wrt.Header().Get("Access-Control-Allow-Methods"); !strings.Contains(got, http.MethodPost) {
|
|
t.Fatalf("POST missing from allowed methods: %q", got)
|
|
}
|
|
if got := wrt.Header().Get("Access-Control-Allow-Headers"); got != "authorization,x-meetingim-apikey" {
|
|
t.Fatalf("requested headers not allowed explicitly: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestAuthFileRequestFromSessionRequiresTenant(t *testing.T) {
|
|
oldSessionStore := globals.sessionStore
|
|
globals.sessionStore = NewSessionStore(time.Minute)
|
|
defer func() { globals.sessionStore = oldSessionStore }()
|
|
|
|
sess, _ := globals.sessionStore.NewSession(httptest.NewRecorder(), "file-test-sid")
|
|
sess.uid = types.Uid(42)
|
|
|
|
if _, _, err := authFileRequest("", "", sess.sid, ""); err != types.ErrFailed {
|
|
t.Fatalf("session without tenant: got %v, want %v", err, types.ErrFailed)
|
|
}
|
|
|
|
sess.tenantID = types.TenantID(7)
|
|
principal, challenge, err := authFileRequest("", "", sess.sid, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if challenge != nil {
|
|
t.Fatal("unexpected challenge")
|
|
}
|
|
if principal.TenantID != sess.tenantID || principal.UID != sess.uid {
|
|
t.Fatalf("principal mismatch: got %+v, want tenant=%d uid=%s", principal, sess.tenantID, sess.uid)
|
|
}
|
|
}
|
|
|
|
func TestAuthFileRequestFromTokenReturnsPrincipal(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
|
|
oldStore := store.Store
|
|
defer func() { store.Store = oldStore }()
|
|
|
|
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
|
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
|
|
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
|
|
tokenAuth.EXPECT().Authenticate(auth.AuthContext{RemoteAddr: "203.0.113.10"}, []byte("token-1")).
|
|
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil)
|
|
store.Store = mockStore
|
|
|
|
principal, challenge, err := authFileRequest("token", base64.StdEncoding.EncodeToString([]byte("token-1")), "", "203.0.113.10")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if challenge != nil {
|
|
t.Fatal("unexpected challenge")
|
|
}
|
|
if principal.TenantID != 7 || principal.UID != 42 {
|
|
t.Fatalf("principal mismatch: %#v", principal)
|
|
}
|
|
}
|
|
|
|
func TestAuthFileRequestAllowsUnauthenticatedNewAccountPath(t *testing.T) {
|
|
principal, challenge, err := authFileRequest("", "", "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if challenge != nil {
|
|
t.Fatal("unexpected challenge")
|
|
}
|
|
if !principal.TenantID.IsZero() || !principal.UID.IsZero() {
|
|
t.Fatalf("principal should be empty for unauthenticated request: %+v", principal)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeLargeFileDownloadDenied(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldFiles := store.Files
|
|
files := mock_store.NewMockFilePersistenceInterface(ctrl)
|
|
store.Files = files
|
|
defer func() { store.Files = oldFiles }()
|
|
|
|
fd := &types.FileDef{
|
|
ObjHeader: types.ObjHeader{Id: types.Uid(100).String(), UpdatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)},
|
|
TenantID: 7,
|
|
Status: types.UploadCompleted,
|
|
MimeType: "text/plain",
|
|
Location: "notes.txt",
|
|
}
|
|
files.EXPECT().UserHasAccess(types.TenantID(7), types.Uid(100).String(), types.Uid(42)).Return(false, nil)
|
|
if err := authorizeLargeFileDownload(7, 42, "", fd); err != types.ErrPermissionDenied {
|
|
t.Fatalf("expected permission denied, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeLargeFileDownloadRejectsCrossTenant(t *testing.T) {
|
|
fd := &types.FileDef{
|
|
ObjHeader: types.ObjHeader{Id: types.Uid(100).String(), UpdatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)},
|
|
TenantID: 8,
|
|
Status: types.UploadCompleted,
|
|
MimeType: "text/plain",
|
|
Location: "notes.txt",
|
|
}
|
|
if err := authorizeLargeFileDownload(7, 42, "", fd); err != types.ErrNotFound {
|
|
t.Fatalf("expected not found for cross-tenant file, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeLargeFileRequestURLRequiresAccessBeforeRedirect(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
|
|
oldStore := store.Store
|
|
oldFiles := store.Files
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Files = oldFiles
|
|
}()
|
|
|
|
fid := types.Uid(100)
|
|
handler := &testLargeFileMediaHandler{fid: fid}
|
|
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
|
files := mock_store.NewMockFilePersistenceInterface(ctrl)
|
|
store.Store = mockStore
|
|
store.Files = files
|
|
|
|
fd := &types.FileDef{
|
|
ObjHeader: types.ObjHeader{Id: fid.String(), UpdatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)},
|
|
TenantID: 7,
|
|
Status: types.UploadCompleted,
|
|
MimeType: "image/jpeg",
|
|
Location: "image.jpeg",
|
|
}
|
|
mockStore.EXPECT().GetMediaHandler().Return(handler)
|
|
files.EXPECT().Get(types.TenantID(7), fid.String()).Return(fd, nil)
|
|
files.EXPECT().UserHasAccess(types.TenantID(7), fid.String(), types.Uid(42)).Return(false, nil)
|
|
|
|
if err := authorizeLargeFileRequestURL(7, 42, "/v0/file/s/file.jpeg"); err != types.ErrPermissionDenied {
|
|
t.Fatalf("expected permission denied, got %v", err)
|
|
}
|
|
if handler.headersCalled {
|
|
t.Fatal("media headers should not be called by authorization helper")
|
|
}
|
|
}
|
|
|
|
func TestContentDispositionAttachmentSanitizesName(t *testing.T) {
|
|
fd := &types.FileDef{
|
|
ObjHeader: types.ObjHeader{Id: types.Uid(100).String()},
|
|
Location: `../bad"name.txt`,
|
|
}
|
|
got := contentDispositionAttachment(fd)
|
|
if got != `attachment; filename=badname.txt` {
|
|
t.Fatalf("unexpected content disposition: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestLargeFileGarbageCollectTenantsScopesDeletes(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldFiles := store.Files
|
|
files := mock_store.NewMockFilePersistenceInterface(ctrl)
|
|
store.Files = files
|
|
defer func() { store.Files = oldFiles }()
|
|
|
|
olderThan := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
|
|
files.EXPECT().DeleteUnused(types.TenantID(7), olderThan, 25).Return(nil)
|
|
files.EXPECT().DeleteUnused(types.TenantID(8), olderThan, 25).Return(nil)
|
|
|
|
largeFileGarbageCollectTenants([]types.TenantID{7, 8}, olderThan, 25)
|
|
}
|