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 TestCanUploadGroupFileHonorsGovernance(t *testing.T) { settings := &types.GroupSettings{MemberUploadFiles: false} if canUploadGroupFile(settings, types.ModeRead|types.ModeWrite) { t.Fatal("ordinary member must not upload when group files are admin-only") } if !canUploadGroupFile(settings, types.ModeRead|types.ModeApprove) { t.Fatal("group admin must be allowed to upload when group files are admin-only") } settings.MemberUploadFiles = true if !canUploadGroupFile(settings, types.ModeRead|types.ModeWrite) { t.Fatal("ordinary member must be allowed when group files are open to all") } if !canUploadGroupFile(nil, types.ModeRead|types.ModeWrite) { t.Fatal("groups without business settings must retain the default allow behavior") } } 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) }