From 89363035105da5effe61ed3acc47677c25235986 Mon Sep 17 00:00:00 2001 From: silverwind Date: Wed, 30 Sep 2026 20:25:14 +0200 Subject: [PATCH] fix: add missing checks to several API and web handlers (#39501) Several handlers skipped checks that their sibling routes or settings already enforce. This brings them in line. - Push mirror API honors `DISABLE_NEW_PUSH` and checks the caller's permission - Media API serves small files with the usual content headers - Issue attachment API ignores comment attachments - Push-to-create respects `FORCE_PRIVATE` - Profile feeds and follow actions respect `ENABLE_FEED` and owner visibility - Tag delete route refuses release tags - Refresh token grant only accepts refresh tokens - Gitea migrations bound the source's page size Co-authored-by: bircni --- routers/api/v1/repo/file.go | 6 ++--- routers/api/v1/repo/issue_attachment.go | 4 ++-- routers/api/v1/repo/mirror.go | 7 +++++- routers/api/v1/repo/mirror_test.go | 23 +++++++++++++++++++ routers/private/hook_post_receive.go | 3 ++- routers/web/auth/oauth2_provider.go | 2 +- routers/web/feed/profile.go | 11 ++++++++- routers/web/repo/release.go | 5 ++++ routers/web/repo/release_test.go | 16 +++++++++++++ routers/web/user/home.go | 8 ------- routers/web/user/profile.go | 7 ++++++ services/migrations/gitea_downloader.go | 5 ++-- services/migrations/gitea_downloader_test.go | 17 ++++++++------ .../integration/api_issue_attachment_test.go | 5 ++++ tests/integration/api_repo_file_get_test.go | 4 ++++ tests/integration/git_push_test.go | 10 ++++++++ tests/integration/oauth_test.go | 9 ++++++++ tests/integration/user_test.go | 9 ++++++++ 18 files changed, 124 insertions(+), 27 deletions(-) diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index 5f74ba2ad61..1900ebaef14 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -162,7 +162,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { // if it's not a pointer, just serve the data directly if !pointer.IsValid() { - _, _ = ctx.Resp.Write(lfsPointerBuf) + httplib.ServeUserContentByReader(ctx.Req, ctx.Resp, int64(len(lfsPointerBuf)), bytes.NewReader(lfsPointerBuf), httplib.ServeHeaderOptions{Filename: blob.Name()}) return } @@ -171,7 +171,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { // If there isn't one, just serve the data directly if errors.Is(err, git_model.ErrLFSObjectNotExist) { - _, _ = ctx.Resp.Write(lfsPointerBuf) + httplib.ServeUserContentByReader(ctx.Req, ctx.Resp, int64(len(lfsPointerBuf)), bytes.NewReader(lfsPointerBuf), httplib.ServeHeaderOptions{Filename: blob.Name()}) return } else if err != nil { ctx.APIErrorInternal(err) @@ -198,7 +198,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { return } defer lfsDataFile.Close() - httplib.ServeUserContentByFile(ctx.Base.Req, ctx.Base.Resp, lfsDataFile, httplib.ServeHeaderOptions{Filename: ctx.Repo.TreePath}) + httplib.ServeUserContentByFile(ctx.Base.Req, ctx.Base.Resp, lfsDataFile, httplib.ServeHeaderOptions{Filename: blob.Name()}) } func getBlobForEntry(ctx *context.APIContext) (blob *git.Blob, entry *git.TreeEntry, lastModified *time.Time) { diff --git a/routers/api/v1/repo/issue_attachment.go b/routers/api/v1/repo/issue_attachment.go index be1389d6e5f..4391bb10ca9 100644 --- a/routers/api/v1/repo/issue_attachment.go +++ b/routers/api/v1/repo/issue_attachment.go @@ -386,8 +386,8 @@ func attachmentBelongsToRepoOrIssue(ctx *context.APIContext, attachment *repo_mo ctx.APIErrorNotFound("no such attachment in repo") return false } - if attachment.IssueID == 0 { - log.Debug("Requested attachment[%d] is not in an issue.", attachment.ID) + if attachment.IssueID == 0 || attachment.CommentID != 0 { + log.Debug("Requested attachment[%d] is not an issue attachment.", attachment.ID) ctx.APIErrorNotFound("no such attachment in issue") return false } else if issue != nil && attachment.IssueID != issue.ID { diff --git a/routers/api/v1/repo/mirror.go b/routers/api/v1/repo/mirror.go index c870ee18fc4..6630d063531 100644 --- a/routers/api/v1/repo/mirror.go +++ b/routers/api/v1/repo/mirror.go @@ -291,6 +291,11 @@ func AddPushMirror(ctx *context.APIContext) { return } + if setting.Mirror.DisableNewPush { + ctx.APIError(http.StatusForbidden, "the site administrator has disabled the creation of new push mirrors") + return + } + pushMirror := web.GetForm[*api.CreatePushMirrorOption](ctx) CreatePushMirror(ctx, pushMirror) } @@ -356,7 +361,7 @@ func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirro address, err := git.ParseRemoteAddr(mirrorOption.RemoteAddress, mirrorOption.RemoteUsername, mirrorOption.RemotePassword) if err == nil { - err = migrations.IsMigrateURLAllowed(address, ctx.ContextUser) + err = migrations.IsMigrateURLAllowed(address, ctx.Doer) } if err != nil { HandleRemoteAddressError(ctx, err) diff --git a/routers/api/v1/repo/mirror_test.go b/routers/api/v1/repo/mirror_test.go index ce1538d66bf..90f50d45e75 100644 --- a/routers/api/v1/repo/mirror_test.go +++ b/routers/api/v1/repo/mirror_test.go @@ -10,13 +10,36 @@ import ( "gitea.dev/models/db" repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" "gitea.dev/modules/setting" + api "gitea.dev/modules/structs" "gitea.dev/modules/test" "gitea.dev/services/contexttest" "github.com/stretchr/testify/assert" ) +func TestCreatePushMirrorUsesCallerPermission(t *testing.T) { + defer test.MockVariableValue(&setting.ImportLocalPaths, true)() + ctx, resp := contexttest.MockAPIContext(t, "user2/repo1") + ctx.Doer = &user_model.User{} + ctx.ContextUser = &user_model.User{AllowImportLocal: true} + + CreatePushMirror(ctx, &api.CreatePushMirrorOption{RemoteAddress: "local-mirror", Interval: "0"}) + + assert.Equal(t, http.StatusUnauthorized, resp.Code) +} + +func TestAddPushMirrorDisabled(t *testing.T) { + defer test.MockVariableValue(&setting.Mirror.DisableNewPush, true)() + ctx, resp := contexttest.MockAPIContext(t, "user2/repo1") + + AddPushMirror(ctx) + + assert.Equal(t, http.StatusForbidden, resp.Code) + assert.Contains(t, resp.Body.String(), "the site administrator has disabled the creation of new push mirrors") +} + // TestPushMirrorSync verifies the endpoint attempts every push mirror instead // of aborting on the first failure, reporting all failed remotes with a 422. // Each remote name is not a configured git remote, so SyncPushMirror fails fast diff --git a/routers/private/hook_post_receive.go b/routers/private/hook_post_receive.go index f59aba063dd..98928c5adcf 100644 --- a/routers/private/hook_post_receive.go +++ b/routers/private/hook_post_receive.go @@ -139,7 +139,8 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts // The repo is empty and being initialized by this push, so there is no // dependent state (webhooks, notifications, visibility fan-out) to reconcile // yet; setting the flags directly is sufficient in this push-to-create case. - if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() { + if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() && + (isPrivate.Value() || !setting.Repository.ForcePrivate || ctx.Doer.IsAdmin) { repo.IsPrivate = isPrivate.Value() if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil { log.Error("failed to update repo is_private: %v", err) diff --git a/routers/web/auth/oauth2_provider.go b/routers/web/auth/oauth2_provider.go index 69fa9dcabdf..58a3008c528 100644 --- a/routers/web/auth/oauth2_provider.go +++ b/routers/web/auth/oauth2_provider.go @@ -576,7 +576,7 @@ func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, server } token, err := oauth2_provider.ParseToken(form.RefreshToken, serverKey) - if err != nil { + if err != nil || token.Kind != oauth2_provider.KindRefreshToken { handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ ErrorCode: oauth2_provider.AccessTokenErrorCodeUnauthorizedClient, ErrorDescription: "unable to parse refresh token", diff --git a/routers/web/feed/profile.go b/routers/web/feed/profile.go index 9fef2a26b5c..688574140b2 100644 --- a/routers/web/feed/profile.go +++ b/routers/web/feed/profile.go @@ -9,7 +9,9 @@ import ( activities_model "gitea.dev/models/activities" "gitea.dev/models/organization" "gitea.dev/models/renderhelper" + user_model "gitea.dev/models/user" "gitea.dev/modules/markup/markdown" + "gitea.dev/modules/setting" "gitea.dev/services/context" feed_service "gitea.dev/services/feed" @@ -28,8 +30,15 @@ func ShowUserFeedAtom(ctx *context.Context) { // showUserFeed show user activity as RSS / Atom feed func showUserFeed(ctx *context.Context, formatType string) { - includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) isOrganisation := ctx.ContextUser.IsOrganization() + if !setting.Other.EnableFeed || + isOrganisation && !organization.HasOrgOrUserVisible(ctx, ctx.ContextUser, ctx.Doer) || + !isOrganisation && !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) { + ctx.NotFound(nil) + return + } + + includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) if ctx.IsSigned && isOrganisation && !includePrivate { // When feed is requested by a member of the organization, // include the private repo's the member has access to. diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index edf029f5b67..3bffbf5b077 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -660,6 +660,11 @@ func deleteReleaseOrTag(ctx *context.Context, isDelTag bool) { return } + if isDelTag && !rel.IsTag { + ctx.HTTPError(http.StatusConflict, "a tag attached to a release cannot be deleted directly") + return + } + if err := release_service.DeleteReleaseByID(ctx, ctx.Repo.Repository, rel, ctx.Doer, isDelTag); err != nil { if release_service.IsErrProtectedTagName(err) { ctx.Flash.Error(ctx.Tr("repo.release.tag_name_protected")) diff --git a/routers/web/repo/release_test.go b/routers/web/repo/release_test.go index 57f831bffda..dc8bb277c4c 100644 --- a/routers/web/repo/release_test.go +++ b/routers/web/repo/release_test.go @@ -4,6 +4,7 @@ package repo import ( + "net/http" "net/http/httptest" "testing" @@ -21,6 +22,21 @@ import ( "github.com/stretchr/testify/require" ) +func TestDeleteTagRetainsReleaseAndAttachments(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, resp := contexttest.MockContext(t, "POST user2/repo1/tags/delete?id=1") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + release := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: 1}) + attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 9, ReleaseID: 1}) + + DeleteTag(ctx) + + assert.Equal(t, http.StatusConflict, resp.Code) + assert.Equal(t, release, unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: 1})) + assert.Equal(t, attachment, unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 9})) +} + func TestNewReleasePost(t *testing.T) { unittest.PrepareTestEnv(t) diff --git a/routers/web/user/home.go b/routers/web/user/home.go index 138340c7633..addb9caec9a 100644 --- a/routers/web/user/home.go +++ b/routers/web/user/home.go @@ -734,18 +734,10 @@ func UsernameSubRoute(ctx *context.Context) { ShowGPGKeys(ctx) } case strings.HasSuffix(username, ".rss"): - if !setting.Other.EnableFeed { - ctx.HTTPError(http.StatusNotFound) - return - } if reloadParam(".rss") { feed.ShowUserFeedRSS(ctx) } case strings.HasSuffix(username, ".atom"): - if !setting.Other.EnableFeed { - ctx.HTTPError(http.StatusNotFound) - return - } if reloadParam(".atom") { feed.ShowUserFeedAtom(ctx) } diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index f48ba604b41..085977e062d 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -322,6 +322,13 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R // ActionUserFollow is for follow/unfollow user request func ActionUserFollow(ctx *context.Context) { + isOrg := ctx.ContextUser.IsOrganization() + if isOrg && !organization.HasOrgOrUserVisible(ctx, ctx.ContextUser, ctx.Doer) || + !isOrg && !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) { + ctx.NotFound(nil) + return + } + var err error switch ctx.FormString("action") { case "follow": diff --git a/services/migrations/gitea_downloader.go b/services/migrations/gitea_downloader.go index 60bff07434b..cd8ae73a293 100644 --- a/services/migrations/gitea_downloader.go +++ b/services/migrations/gitea_downloader.go @@ -107,9 +107,8 @@ func NewGiteaDownloader(ctx context.Context, baseURL, repoPath, username, passwo if err != nil { log.Info("Unable to get global API settings. Ignoring these.") log.Debug("giteaClient.GetGlobalAPISettings. Error: %v", err) - } - if apiConf != nil { - maxPerPage = apiConf.MaxResponseItems + } else if apiConf != nil && apiConf.MaxResponseItems > 0 { + maxPerPage = min(apiConf.MaxResponseItems, 100) } return &GiteaDownloader{ diff --git a/services/migrations/gitea_downloader_test.go b/services/migrations/gitea_downloader_test.go index 4b1dbdf22bd..9ff4e4b24d9 100644 --- a/services/migrations/gitea_downloader_test.go +++ b/services/migrations/gitea_downloader_test.go @@ -5,6 +5,7 @@ package migrations import ( "fmt" + "math" "net/http" "net/http/httptest" "os" @@ -317,15 +318,16 @@ func TestGiteaDownloadRepo(t *testing.T) { func TestGiteaDownloadCommentsPaging(t *testing.T) { for _, tc := range []struct { - maxResponseItems, commentCount, requests int - paginated bool + maxResponseItems, pageSize, commentCount, requests int + paginated bool }{ - {maxResponseItems: 2, commentCount: 2, requests: 2}, - {maxResponseItems: 2, commentCount: 3, requests: 1}, - {maxResponseItems: 2, commentCount: 4, requests: 3, paginated: true}, - {maxResponseItems: 0, commentCount: 0, requests: 1}, + {maxResponseItems: 2, pageSize: 2, commentCount: 2, requests: 2}, + {maxResponseItems: 2, pageSize: 2, commentCount: 3, requests: 1}, + {maxResponseItems: 2, pageSize: 2, commentCount: 4, requests: 3, paginated: true}, + {maxResponseItems: 0, pageSize: 10, commentCount: 0, requests: 1}, + {maxResponseItems: math.MaxInt, pageSize: 100, commentCount: 0, requests: 1}, } { - t.Run(strconv.Itoa(tc.commentCount), func(t *testing.T) { + t.Run(fmt.Sprintf("maxResponseItems=%d/comments=%d", tc.maxResponseItems, tc.commentCount), func(t *testing.T) { commentRequests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -352,6 +354,7 @@ func TestGiteaDownloadCommentsPaging(t *testing.T) { downloader, err := NewGiteaDownloader(t.Context(), server.URL, "o/r", "", "", "") require.NoError(t, err) + require.Equal(t, tc.pageSize, downloader.maxPerPage) comments, _, err := downloader.GetComments(t.Context(), &base.Issue{Number: 1}) require.NoError(t, err) diff --git a/tests/integration/api_issue_attachment_test.go b/tests/integration/api_issue_attachment_test.go index eb0db160079..9656946eefc 100644 --- a/tests/integration/api_issue_attachment_test.go +++ b/tests/integration/api_issue_attachment_test.go @@ -38,6 +38,11 @@ func TestAPIGetIssueAttachment(t *testing.T) { apiAttachment := DecodeJSON(t, resp, &api.Attachment{}) unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) + + commentAttachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 3, RepoID: repo.ID}) + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets/%d", repoOwner.Name, repo.Name, unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: commentAttachment.IssueID}).Index, commentAttachment.ID)). + AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusNotFound) } func TestAPIListIssueAttachments(t *testing.T) { diff --git a/tests/integration/api_repo_file_get_test.go b/tests/integration/api_repo_file_get_test.go index 267983409de..a785cd853d5 100644 --- a/tests/integration/api_repo_file_get_test.go +++ b/tests/integration/api_repo_file_get_test.go @@ -22,6 +22,10 @@ func TestAPIGetRawFileOrLFS(t *testing.T) { resp := MakeRequest(t, req, http.StatusOK) assert.Equal(t, "# repo1\n\nDescription for repo1", resp.Body.String()) + req = NewRequest(t, "GET", "/api/v1/repos/user2/repo2/media/test.xml").AddTokenAuth(getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository)) + resp = MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type")) + // Test with LFS onGiteaRun(t, func(t *testing.T, u *url.URL) { createLFSTestRepository(t, "repo-lfs-test") diff --git a/tests/integration/git_push_test.go b/tests/integration/git_push_test.go index 284a07875e5..934ae66e198 100644 --- a/tests/integration/git_push_test.go +++ b/tests/integration/git_push_test.go @@ -16,6 +16,8 @@ import ( user_model "gitea.dev/models/user" "gitea.dev/modules/git" "gitea.dev/modules/git/gitcmd" + "gitea.dev/modules/setting" + "gitea.dev/modules/test" repo_service "gitea.dev/services/repository" "github.com/stretchr/testify/assert" @@ -175,6 +177,14 @@ func TestGitPushVisibilityOption(t *testing.T) { doGitPushTestRepository(gitPath, "origin", "branch2", "-o", "repo.private=false")(t) repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID}) assert.True(t, repo.IsPrivate, "repo.private option must be ignored on an existing repository") + + defer test.MockVariableValue(&setting.Repository.ForcePrivate, true)() + forcedRepo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{Name: "repo-visibility-forced", DefaultBranch: "master", IsPrivate: true}) + require.NoError(t, err) + u.Path = forcedRepo.FullName() + ".git" + doGitAddRemote(gitPath, "forced", u)(t) + doGitPushTestRepository(gitPath, "forced", "master", "-o", "repo.private=false")(t) + assert.True(t, unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: forcedRepo.ID}).IsPrivate) }) } diff --git a/tests/integration/oauth_test.go b/tests/integration/oauth_test.go index e5d95d783a2..dbf9d7f226b 100644 --- a/tests/integration/oauth_test.go +++ b/tests/integration/oauth_test.go @@ -571,6 +571,15 @@ func testRefreshTokenInvalidation(t *testing.T) { assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode)) assert.Equal(t, "unable to parse refresh token", parsedError.ErrorDescription) + req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "refresh_token", + "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", + "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", + "redirect_uri": "https://example.com", + "refresh_token": parsed.AccessToken, + }) + MakeRequest(t, req, http.StatusBadRequest) + req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ "grant_type": "refresh_token", "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", diff --git a/tests/integration/user_test.go b/tests/integration/user_test.go index 46e6af2fd13..f6df1ee1722 100644 --- a/tests/integration/user_test.go +++ b/tests/integration/user_test.go @@ -84,6 +84,7 @@ func testViewLimitedAndPrivateUserAndRename(t *testing.T) { org22 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 22}) req := NewRequest(t, "GET", "/"+org22.Name) MakeRequest(t, req, http.StatusNotFound) + MakeRequest(t, NewRequest(t, "GET", "/"+org22.Name).SetHeader("Accept", "application/rss+xml"), http.StatusNotFound) session := loginUser(t, "user1") oldName := org22.Name @@ -106,6 +107,8 @@ func testViewLimitedAndPrivateUserAndRename(t *testing.T) { org23 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23}) req = NewRequest(t, "GET", "/"+org23.Name) MakeRequest(t, req, http.StatusNotFound) + strangerSession := loginUser(t, "user4") + strangerSession.MakeRequest(t, NewRequest(t, "POST", "/"+org23.Name+"?action=follow"), http.StatusNotFound) oldName = org23.Name newName = "org23_renamed" @@ -127,6 +130,8 @@ func testViewLimitedAndPrivateUserAndRename(t *testing.T) { user31 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 31}) req = NewRequest(t, "GET", "/"+user31.Name) MakeRequest(t, req, http.StatusNotFound) + MakeRequest(t, NewRequest(t, "GET", "/"+user31.Name).SetHeader("Accept", "application/rss+xml"), http.StatusNotFound) + strangerSession.MakeRequest(t, NewRequest(t, "POST", "/"+user31.Name+"?action=follow"), http.StatusNotFound) oldName = user31.Name newName = "user31_renamed" @@ -330,6 +335,10 @@ func testGetUserRss(t *testing.T) { session := loginUser(t, "user2") req = NewRequestf(t, "GET", "/non-existent-user.rss") session.MakeRequest(t, req, http.StatusNotFound) + + defer test.MockVariableValue(&setting.Other.EnableFeed, false)() + MakeRequest(t, NewRequestf(t, "GET", "/%s.rss", user34), http.StatusNotFound) + MakeRequest(t, NewRequestf(t, "GET", "/%s", user34).SetHeader("Accept", "application/rss+xml"), http.StatusNotFound) } func testUserListStopWatches(t *testing.T) {