Files
im_server/server/hdl_files_test.go
T

259 lines
8.7 KiB
Go

package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"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
}
func (h *testLargeFileMediaHandler) Init(string) error { return nil }
func (h *testLargeFileMediaHandler) Headers(types.TenantID, string, *url.URL, http.Header, bool) (http.Header, int, error) {
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 types.ZeroUid
}
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 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)
}
func TestLargeFileReceiveMeetingNoteUploadCreatesNoteFile(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
oldFiles := store.Files
oldMeetingNotes := store.MeetingNotes
oldKafkaBus := kafkaBus
defer func() {
store.Store = oldStore
store.Files = oldFiles
store.MeetingNotes = oldMeetingNotes
kafkaBus = oldKafkaBus
}()
handler := &testLargeFileMediaHandler{}
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
files := mock_store.NewMockFilePersistenceInterface(ctrl)
notes := &testMeetingNotesStore{}
store.Store = mockStore
store.Files = files
store.MeetingNotes = notes
kafkaBus = nil
fileID := types.Uid(101)
mockStore.EXPECT().GetMediaHandler().Return(handler).AnyTimes()
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
mockStore.EXPECT().GetUidString().Return(fileID.String())
mockStore.EXPECT().GetUidString().Return("note-1")
tokenAuth.EXPECT().Authenticate(auth.AuthContext{RemoteAddr: "192.0.2.1:12345"}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil)
files.EXPECT().FinishUpload(types.TenantID(7), gomock.Any(), true, int64(11)).DoAndReturn(
func(_ types.TenantID, fd *types.FileDef, _ bool, size int64) (*types.FileDef, error) {
fd.Status = types.UploadCompleted
fd.Size = size
return fd, nil
})
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("id", "upload-meeting-note-1"); err != nil {
t.Fatal(err)
}
if err := writer.WriteField("purpose", fileUploadPurposeMeetingNote); err != nil {
t.Fatal(err)
}
if err := writer.WriteField("meeting_id", "mtg_test"); err != nil {
t.Fatal(err)
}
part, err := writer.CreateFormFile("file", "notes.txt")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write([]byte("hello notes")); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/v0/file/u/", &body)
req.RemoteAddr = "192.0.2.1:12345"
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-MeetingIM-Auth", "token "+base64.StdEncoding.EncodeToString([]byte("token-1")))
rec := httptest.NewRecorder()
largeFileReceiveHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var resp ServerComMessage
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v body=%s", err, rec.Body.String())
}
params := resp.Ctrl.Params.(map[string]any)
if params["file_id"] != fileID.String() || params["purpose"] != fileUploadPurposeMeetingNote ||
params["meeting_id"] != "mtg_test" || params["note_file_id"] != "mnote_note-1" {
t.Fatalf("unexpected upload params: %#v", params)
}
if handler.data != "hello notes" || handler.def.OriginalName != "notes.txt" || notes.createdFileID != fileID.String() || notes.createdMeetingID != "mtg_test" || notes.createdIdempotencyKey != "upload-meeting-note-1" {
t.Fatalf("unexpected side effects: handler=%#v notes=%#v", handler, notes)
}
}