diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /tests/integration | |
parent | Initial commit. (diff) | |
download | forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip |
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
298 files changed, 60984 insertions, 0 deletions
diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000..f0fbf94 --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,123 @@ +# Integration tests + +Thank you for your effort to provide good software tests for Forgejo. +Please also read the general testing instructions in the +[Forgejo contributor documentation](https://forgejo.org/docs/next/contributor/testing/). + +This file is meant to provide specific information for the integration tests +as well as some tips and tricks you should know. + +Feel free to extend this file with more instructions if you feel like you have something to share! + + +## How to run the tests? + +Before running any tests, please ensure you perform a clean build: + +``` +make clean build +``` + +Integration tests can be run with make commands for the +appropriate backends, namely: +```shell +make test-sqlite +make test-pgsql +make test-mysql +``` + + +### Run tests via local forgejo runner + +If you have a [forgejo runner](https://code.forgejo.org/forgejo/runner/), +you can use it to run the test jobs: + +#### Run all jobs + +``` +forgejo-runner exec -W .forgejo/workflows/testing.yml --event=pull_request +``` + +Warning: This file defines many jobs, so it will be resource-intensive and therefore not recommended. + +#### Run single job + +```SHELL +forgejo-runner exec -W .forgejo/workflows/testing.yml --event=pull_request -j <job_name> +``` + +You can list all job names via: + +```SHELL +forgejo-runner exec -W .forgejo/workflows/testing.yml --event=pull_request -l +``` + +### Run sqlite integration tests +Start tests +``` +make test-sqlite +``` + +### Run MySQL integration tests +Setup a MySQL database inside docker +``` +docker run -e "MYSQL_DATABASE=test" -e "MYSQL_ALLOW_EMPTY_PASSWORD=yes" -p 3306:3306 --rm --name mysql mysql:latest #(just ctrl-c to stop db and clean the container) +docker run -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" --rm --name elasticsearch elasticsearch:7.6.0 #(in a second terminal, just ctrl-c to stop db and clean the container) +``` +Start tests based on the database container +``` +TEST_MYSQL_HOST=localhost:3306 TEST_MYSQL_DBNAME=test TEST_MYSQL_USERNAME=root TEST_MYSQL_PASSWORD='' make test-mysql +``` + +### Run pgsql integration tests +Setup a pgsql database inside docker +``` +docker run -e "POSTGRES_DB=test" -p 5432:5432 --rm --name pgsql postgres:latest #(just ctrl-c to stop db and clean the container) +``` +Start tests based on the database container +``` +TEST_PGSQL_HOST=localhost:5432 TEST_PGSQL_DBNAME=test TEST_PGSQL_USERNAME=postgres TEST_PGSQL_PASSWORD=postgres make test-pgsql +``` + +### Running individual tests + +Example command to run GPG test: + +For SQLite: + +``` +make test-sqlite#GPG +``` + +For other databases (replace `mysql` to `pgsql`): + +``` +TEST_MYSQL_HOST=localhost:1433 TEST_MYSQL_DBNAME=test TEST_MYSQL_USERNAME=sa TEST_MYSQL_PASSWORD=MwantsaSecurePassword1 make test-mysql#GPG +``` + +## Setting timeouts for declaring long-tests and long-flushes + +We appreciate that some testing machines may not be very powerful and +the default timeouts for declaring a slow test or a slow clean-up flush +may not be appropriate. + +You can either: + +* Within the test ini file set the following section: + +```ini +[integration-tests] +SLOW_TEST = 10s ; 10s is the default value +SLOW_FLUSH = 5S ; 5s is the default value +``` + +* Set the following environment variables: + +```bash +GITEA_SLOW_TEST_TIME="10s" GITEA_SLOW_FLUSH_TIME="5s" make test-sqlite +``` + +## Tips and tricks + +If you know noteworthy tests that can act as an inspiration for new tests, +please add some details here. diff --git a/tests/integration/actions_commit_status_test.go b/tests/integration/actions_commit_status_test.go new file mode 100644 index 0000000..ace0fbd --- /dev/null +++ b/tests/integration/actions_commit_status_test.go @@ -0,0 +1,49 @@ +// Copyright 20124 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/url" + "testing" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/automerge" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActionsAutomerge(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + assert.True(t, setting.Actions.Enabled, "Actions should be enabled") + + ctx := db.DefaultContext + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}) + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 292}) + + assert.False(t, pr.HasMerged, "PR should not be merged") + assert.Equal(t, issues_model.PullRequestStatusMergeable, pr.Status, "PR should be mergeable") + + scheduled, err := automerge.ScheduleAutoMerge(ctx, user, pr, repo_model.MergeStyleMerge, "Dummy") + + require.NoError(t, err, "PR should be scheduled for automerge") + assert.True(t, scheduled, "PR should be scheduled for automerge") + + actions.CreateCommitStatus(ctx, job) + + pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}) + + assert.True(t, pr.HasMerged, "PR should be merged") + }, + ) +} diff --git a/tests/integration/actions_route_test.go b/tests/integration/actions_route_test.go new file mode 100644 index 0000000..10618c8 --- /dev/null +++ b/tests/integration/actions_route_test.go @@ -0,0 +1,184 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "testing" + + actions_model "code.gitea.io/gitea/models/actions" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func GetWorkflowRunRedirectURI(t *testing.T, repoURL, workflow string) string { + t.Helper() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/actions/workflows/%s/runs/latest", repoURL, workflow)) + resp := MakeRequest(t, req, http.StatusTemporaryRedirect) + + return resp.Header().Get("Location") +} + +func TestActionsWebRouteLatestWorkflowRun(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // create the repo + repo, _, f := tests.CreateDeclarativeRepo(t, user2, "actionsTestRepo", + []unit_model.Type{unit_model.TypeActions}, nil, + []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".gitea/workflows/workflow-1.yml", + ContentReader: strings.NewReader("name: workflow-1\non:\n push:\njobs:\n job-1:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + }, + { + Operation: "create", + TreePath: ".gitea/workflows/workflow-2.yml", + ContentReader: strings.NewReader("name: workflow-2\non:\n push:\njobs:\n job-2:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + }, + }, + ) + defer f() + + repoURL := repo.HTMLURL() + + t.Run("valid workflows", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // two runs have been created + assert.Equal(t, 2, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID})) + + // Get the redirect URIs for both workflows + workflowOneURI := GetWorkflowRunRedirectURI(t, repoURL, "workflow-1.yml") + workflowTwoURI := GetWorkflowRunRedirectURI(t, repoURL, "workflow-2.yml") + + // Verify that the two are different. + assert.NotEqual(t, workflowOneURI, workflowTwoURI) + + // Verify that each points to the correct workflow. + workflowOne := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID, Index: 1}) + err := workflowOne.LoadAttributes(context.Background()) + require.NoError(t, err) + assert.Equal(t, workflowOneURI, workflowOne.HTMLURL()) + + workflowTwo := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID, Index: 2}) + err = workflowTwo.LoadAttributes(context.Background()) + require.NoError(t, err) + assert.Equal(t, workflowTwoURI, workflowTwo.HTMLURL()) + }) + + t.Run("check if workflow page shows file name", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Get the redirect URI + workflow := "workflow-1.yml" + workflowOneURI := GetWorkflowRunRedirectURI(t, repoURL, workflow) + + // Fetch the page that shows information about the run initiated by "workflow-1.yml". + // routers/web/repo/actions/view.go: data-workflow-url is constructed using data-workflow-name. + req := NewRequest(t, "GET", workflowOneURI) + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + // Verify that URL of the workflow is shown correctly. + expectedURL := fmt.Sprintf("/user2/actionsTestRepo/actions?workflow=%s", workflow) + htmlDoc.AssertElement(t, fmt.Sprintf("#repo-action-view[data-workflow-url=\"%s\"]", expectedURL), true) + }) + + t.Run("existing workflow, non-existent branch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/actions/workflows/workflow-1.yml/runs/latest?branch=foobar", repoURL)) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("non-existing workflow", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/actions/workflows/workflow-3.yml/runs/latest", repoURL)) + MakeRequest(t, req, http.StatusNotFound) + }) + }) +} + +func TestActionsWebRouteLatestRun(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // create the repo + repo, _, f := tests.CreateDeclarativeRepo(t, user2, "", + []unit_model.Type{unit_model.TypeActions}, nil, + []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".gitea/workflows/pr.yml", + ContentReader: strings.NewReader("name: test\non:\n push:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + }, + }, + ) + defer f() + + // a run has been created + assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID})) + + // Hit the `/actions/runs/latest` route + req := NewRequest(t, "GET", fmt.Sprintf("%s/actions/runs/latest", repo.HTMLURL())) + resp := MakeRequest(t, req, http.StatusTemporaryRedirect) + + // Verify that it redirects to the run we just created + workflow := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID}) + err := workflow.LoadAttributes(context.Background()) + require.NoError(t, err) + + assert.Equal(t, workflow.HTMLURL(), resp.Header().Get("Location")) + }) +} + +func TestActionsArtifactDeletion(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // create the repo + repo, _, f := tests.CreateDeclarativeRepo(t, user2, "", + []unit_model.Type{unit_model.TypeActions}, nil, + []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".gitea/workflows/pr.yml", + ContentReader: strings.NewReader("name: test\non:\n push:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + }, + }, + ) + defer f() + + // a run has been created + assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID})) + + // Load the run we just created + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID}) + err := run.LoadAttributes(context.Background()) + require.NoError(t, err) + + // Visit it's web view + req := NewRequest(t, "GET", run.HTMLURL()) + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + // Assert that the artifact deletion markup exists + htmlDoc.AssertElement(t, "[data-locale-confirm-delete-artifact]", true) + }) +} diff --git a/tests/integration/actions_trigger_test.go b/tests/integration/actions_trigger_test.go new file mode 100644 index 0000000..dfd1f75 --- /dev/null +++ b/tests/integration/actions_trigger_test.go @@ -0,0 +1,761 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "testing" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + actions_module "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + webhook_module "code.gitea.io/gitea/modules/webhook" + actions_service "code.gitea.io/gitea/services/actions" + issue_service "code.gitea.io/gitea/services/issue" + pull_service "code.gitea.io/gitea/services/pull" + release_service "code.gitea.io/gitea/services/release" + repo_service "code.gitea.io/gitea/services/repository" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPullRequestCommitStatus(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the base repo + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + // prepare the repository + files := make([]*files_service.ChangeRepoFile, 0, 10) + for _, onType := range []string{ + "opened", + "synchronize", + "labeled", + "unlabeled", + "assigned", + "unassigned", + "milestoned", + "demilestoned", + "closed", + "reopened", + } { + files = append(files, &files_service.ChangeRepoFile{ + Operation: "create", + TreePath: fmt.Sprintf(".forgejo/workflows/%s.yml", onType), + ContentReader: strings.NewReader(fmt.Sprintf(`name: %[1]s +on: + pull_request: + types: + - %[1]s +jobs: + %[1]s: + runs-on: docker + steps: + - run: true +`, onType)), + }) + } + baseRepo, _, f := tests.CreateDeclarativeRepo(t, user2, "repo-pull-request", + []unit_model.Type{unit_model.TypeActions}, nil, files) + defer f() + baseGitRepo, err := gitrepo.OpenRepository(db.DefaultContext, baseRepo) + require.NoError(t, err) + defer func() { + baseGitRepo.Close() + }() + + // prepare the repository labels + labelStr := "/api/v1/repos/user2/repo-pull-request/labels" + labelsCount := 2 + labels := make([]*api.Label, labelsCount) + for i := 0; i < labelsCount; i++ { + color := "abcdef" + req := NewRequestWithJSON(t, "POST", labelStr, &api.CreateLabelOption{ + Name: fmt.Sprintf("label%d", i), + Color: color, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + labels[i] = new(api.Label) + DecodeJSON(t, resp, &labels[i]) + assert.Equal(t, color, labels[i].Color) + } + + // create the pull request + testEditFileToNewBranch(t, session, "user2", "repo-pull-request", "main", "wip-something", "README.md", "Hello, world 1") + testPullCreate(t, session, "user2", "repo-pull-request", true, "main", "wip-something", "Commit status PR") + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: baseRepo.ID}) + require.NoError(t, pr.LoadIssue(db.DefaultContext)) + + // prepare the assignees + issueURL := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%s", "user2", "repo-pull-request", fmt.Sprintf("%d", pr.Issue.Index)) + + // prepare the labels + labelURL := fmt.Sprintf("%s/labels", issueURL) + + // prepare the milestone + milestoneStr := "/api/v1/repos/user2/repo-pull-request/milestones" + req := NewRequestWithJSON(t, "POST", milestoneStr, &api.CreateMilestoneOption{ + Title: "mymilestone", + State: "open", + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + milestone := new(api.Milestone) + DecodeJSON(t, resp, &milestone) + + // check that one of the status associated with the commit sha matches both + // context & state + checkCommitStatus := func(sha, context string, state api.CommitStatusState) bool { + commitStatuses, _, err := git_model.GetLatestCommitStatus(db.DefaultContext, pr.BaseRepoID, sha, db.ListOptionsAll) + require.NoError(t, err) + for _, commitStatus := range commitStatuses { + if state == commitStatus.State && context == commitStatus.Context { + return true + } + } + return false + } + + assertActionRun := func(t *testing.T, sha, onType string, action api.HookIssueAction, actionRun *actions_model.ActionRun) { + assert.Equal(t, fmt.Sprintf("%s.yml", onType), actionRun.WorkflowID) + assert.Equal(t, sha, actionRun.CommitSHA) + assert.Equal(t, actions_module.GithubEventPullRequest, actionRun.TriggerEvent) + event, err := actionRun.GetPullRequestEventPayload() + require.NoError(t, err) + assert.Equal(t, action, event.Action) + } + + type assertType func(t *testing.T, sha, onType string, action api.HookIssueAction, actionRuns []*actions_model.ActionRun) + assertActionRuns := func(t *testing.T, sha, onType string, action api.HookIssueAction, actionRuns []*actions_model.ActionRun) { + require.Len(t, actionRuns, 1) + assertActionRun(t, sha, onType, action, actionRuns[0]) + } + + for _, testCase := range []struct { + onType string + jobID string + doSomething func() + actionRunCount int + action api.HookIssueAction + assert assertType + }{ + { + onType: "opened", + doSomething: func() {}, + actionRunCount: 1, + action: api.HookIssueOpened, + assert: assertActionRuns, + }, + { + onType: "synchronize", + doSomething: func() { + testEditFile(t, session, "user2", "repo-pull-request", "wip-something", "README.md", "Hello, world 2") + }, + actionRunCount: 1, + action: api.HookIssueSynchronized, + assert: assertActionRuns, + }, + { + onType: "labeled", + doSomething: func() { + req := NewRequestWithJSON(t, "POST", labelURL, &api.IssueLabelsOption{ + Labels: []any{labels[0].ID, labels[1].ID}, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + }, + actionRunCount: 2, + action: api.HookIssueLabelUpdated, + assert: func(t *testing.T, sha, onType string, action api.HookIssueAction, actionRuns []*actions_model.ActionRun) { + assertActionRun(t, sha, onType, api.HookIssueLabelUpdated, actionRuns[0]) + assertActionRun(t, sha, onType, api.HookIssueLabelUpdated, actionRuns[1]) + }, + }, + { + onType: "unlabeled", + doSomething: func() { + req := NewRequestWithJSON(t, "PUT", labelURL, &api.IssueLabelsOption{ + Labels: []any{labels[0].ID}, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + }, + actionRunCount: 3, + action: api.HookIssueLabelCleared, + assert: func(t *testing.T, sha, onType string, action api.HookIssueAction, actionRuns []*actions_model.ActionRun) { + foundPayloadWithLabels := false + knownLabels := []string{"label0", "label1"} + for _, actionRun := range actionRuns { + assert.Equal(t, sha, actionRun.CommitSHA) + assert.Equal(t, actions_module.GithubEventPullRequest, actionRun.TriggerEvent) + event, err := actionRun.GetPullRequestEventPayload() + require.NoError(t, err) + switch event.Action { + case api.HookIssueLabelUpdated: + assert.Equal(t, "labeled.yml", actionRun.WorkflowID) + assert.Equal(t, "label0", event.Label.Name) + require.Len(t, event.PullRequest.Labels, 1) + assert.Contains(t, "label0", event.PullRequest.Labels[0].Name) + case api.HookIssueLabelCleared: + assert.Equal(t, "unlabeled.yml", actionRun.WorkflowID) + assert.Contains(t, knownLabels, event.Label.Name) + if len(event.PullRequest.Labels) > 0 { + foundPayloadWithLabels = true + assert.Contains(t, knownLabels, event.PullRequest.Labels[0].Name) + } + default: + require.Fail(t, fmt.Sprintf("unexpected action '%s'", event.Action)) + } + } + assert.True(t, foundPayloadWithLabels, "expected at least one clear label payload with non empty labels") + }, + }, + { + onType: "assigned", + doSomething: func() { + req := NewRequestWithJSON(t, "PATCH", issueURL, &api.EditIssueOption{ + Assignees: []string{"user2"}, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + }, + actionRunCount: 1, + action: api.HookIssueAssigned, + assert: assertActionRuns, + }, + { + onType: "unassigned", + doSomething: func() { + req := NewRequestWithJSON(t, "PATCH", issueURL, &api.EditIssueOption{ + Assignees: []string{}, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + }, + actionRunCount: 1, + action: api.HookIssueUnassigned, + assert: assertActionRuns, + }, + { + onType: "milestoned", + doSomething: func() { + req := NewRequestWithJSON(t, "PATCH", issueURL, &api.EditIssueOption{ + Milestone: &milestone.ID, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + }, + actionRunCount: 1, + action: api.HookIssueMilestoned, + assert: assertActionRuns, + }, + { + onType: "demilestoned", + doSomething: func() { + var zero int64 + req := NewRequestWithJSON(t, "PATCH", issueURL, &api.EditIssueOption{ + Milestone: &zero, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + }, + actionRunCount: 1, + action: api.HookIssueDemilestoned, + assert: assertActionRuns, + }, + { + onType: "closed", + doSomething: func() { + sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName()) + require.NoError(t, err) + err = issue_service.ChangeStatus(db.DefaultContext, pr.Issue, user2, sha, true) + require.NoError(t, err) + }, + actionRunCount: 1, + action: api.HookIssueClosed, + assert: assertActionRuns, + }, + { + onType: "reopened", + doSomething: func() { + sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName()) + require.NoError(t, err) + err = issue_service.ChangeStatus(db.DefaultContext, pr.Issue, user2, sha, false) + require.NoError(t, err) + }, + actionRunCount: 1, + action: api.HookIssueReOpened, + assert: assertActionRuns, + }, + } { + t.Run(testCase.onType, func(t *testing.T) { + defer func() { + // cleanup leftovers, start from scratch + _, err = db.DeleteByBean(db.DefaultContext, actions_model.ActionRun{RepoID: baseRepo.ID}) + require.NoError(t, err) + _, err = db.DeleteByBean(db.DefaultContext, actions_model.ActionRunJob{RepoID: baseRepo.ID}) + require.NoError(t, err) + }() + + // trigger the onType event + testCase.doSomething() + count := testCase.actionRunCount + context := fmt.Sprintf("%[1]s / %[1]s (pull_request)", testCase.onType) + + var actionRuns []*actions_model.ActionRun + + // wait for ActionRun(s) to be created + require.Eventually(t, func() bool { + actionRuns = make([]*actions_model.ActionRun, 0) + require.NoError(t, db.GetEngine(db.DefaultContext).Where("repo_id=?", baseRepo.ID).Find(&actionRuns)) + return len(actionRuns) == count + }, 30*time.Second, 1*time.Second) + + // verify the expected ActionRuns were created + sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName()) + require.NoError(t, err) + // verify the commit status changes to CommitStatusSuccess when the job changes to StatusSuccess + require.Eventually(t, func() bool { + return checkCommitStatus(sha, context, api.CommitStatusPending) + }, 30*time.Second, 1*time.Second) + for _, actionRun := range actionRuns { + // verify the expected ActionRunJob was created and is StatusWaiting + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, CommitSHA: sha}) + assert.Equal(t, actions_model.StatusWaiting, job.Status) + + // change the state of the job to success + job.Status = actions_model.StatusSuccess + actions_service.CreateCommitStatus(db.DefaultContext, job) + } + // verify the commit status changed to CommitStatusSuccess because the job(s) changed to StatusSuccess + require.Eventually(t, func() bool { + return checkCommitStatus(sha, context, api.CommitStatusSuccess) + }, 30*time.Second, 1*time.Second) + + testCase.assert(t, sha, testCase.onType, testCase.action, actionRuns) + }) + } + }) +} + +func TestPullRequestTargetEvent(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the base repo + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the forked repo + + // create the base repo + baseRepo, _, f := tests.CreateDeclarativeRepo(t, user2, "repo-pull-request-target", + []unit_model.Type{unit_model.TypeActions}, nil, nil, + ) + defer f() + + // create the forked repo + forkedRepo, err := repo_service.ForkRepositoryAndUpdates(git.DefaultContext, user2, org3, repo_service.ForkRepoOptions{ + BaseRepo: baseRepo, + Name: "forked-repo-pull-request-target", + Description: "test pull-request-target event", + }) + require.NoError(t, err) + assert.NotEmpty(t, forkedRepo) + + // add workflow file to the base repo + addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, user2, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".gitea/workflows/pr.yml", + ContentReader: strings.NewReader("name: test\non:\n pull_request_target:\n paths:\n - 'file_*.txt'\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + }, + }, + Message: "add workflow", + OldBranch: "main", + NewBranch: "main", + Author: &files_service.IdentityOptions{ + Name: user2.Name, + Email: user2.Email, + }, + Committer: &files_service.IdentityOptions{ + Name: user2.Name, + Email: user2.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + require.NoError(t, err) + assert.NotEmpty(t, addWorkflowToBaseResp) + + // add a new file to the forked repo + addFileToForkedResp, err := files_service.ChangeRepoFiles(git.DefaultContext, forkedRepo, org3, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "file_1.txt", + ContentReader: strings.NewReader("file1"), + }, + }, + Message: "add file1", + OldBranch: "main", + NewBranch: "fork-branch-1", + Author: &files_service.IdentityOptions{ + Name: org3.Name, + Email: org3.Email, + }, + Committer: &files_service.IdentityOptions{ + Name: org3.Name, + Email: org3.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + require.NoError(t, err) + assert.NotEmpty(t, addFileToForkedResp) + + // create Pull + pullIssue := &issues_model.Issue{ + RepoID: baseRepo.ID, + Title: "Test pull-request-target-event", + PosterID: org3.ID, + Poster: org3, + IsPull: true, + } + pullRequest := &issues_model.PullRequest{ + HeadRepoID: forkedRepo.ID, + BaseRepoID: baseRepo.ID, + HeadBranch: "fork-branch-1", + BaseBranch: "main", + HeadRepo: forkedRepo, + BaseRepo: baseRepo, + Type: issues_model.PullRequestGitea, + } + err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil) + require.NoError(t, err) + // if a PR "synchronized" event races the "opened" event by having the same SHA, it must be skipped. See https://codeberg.org/forgejo/forgejo/issues/2009. + assert.True(t, actions_service.SkipPullRequestEvent(git.DefaultContext, webhook_module.HookEventPullRequestSync, baseRepo.ID, addFileToForkedResp.Commit.SHA)) + + // load and compare ActionRun + assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID})) + actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID}) + assert.Equal(t, addFileToForkedResp.Commit.SHA, actionRun.CommitSHA) + assert.Equal(t, actions_module.GithubEventPullRequestTarget, actionRun.TriggerEvent) + + // add another file whose name cannot match the specified path + addFileToForkedResp, err = files_service.ChangeRepoFiles(git.DefaultContext, forkedRepo, org3, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "foo.txt", + ContentReader: strings.NewReader("foo"), + }, + }, + Message: "add foo.txt", + OldBranch: "main", + NewBranch: "fork-branch-2", + Author: &files_service.IdentityOptions{ + Name: org3.Name, + Email: org3.Email, + }, + Committer: &files_service.IdentityOptions{ + Name: org3.Name, + Email: org3.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + require.NoError(t, err) + assert.NotEmpty(t, addFileToForkedResp) + + // create Pull + pullIssue = &issues_model.Issue{ + RepoID: baseRepo.ID, + Title: "A mismatched path cannot trigger pull-request-target-event", + PosterID: org3.ID, + Poster: org3, + IsPull: true, + } + pullRequest = &issues_model.PullRequest{ + HeadRepoID: forkedRepo.ID, + BaseRepoID: baseRepo.ID, + HeadBranch: "fork-branch-2", + BaseBranch: "main", + HeadRepo: forkedRepo, + BaseRepo: baseRepo, + Type: issues_model.PullRequestGitea, + } + err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil) + require.NoError(t, err) + + // the new pull request cannot trigger actions, so there is still only 1 record + assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID})) + }) +} + +func TestSkipCI(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user2") + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // create the repo + repo, _, f := tests.CreateDeclarativeRepo(t, user2, "skip-ci", + []unit_model.Type{unit_model.TypeActions}, nil, + []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".gitea/workflows/pr.yml", + ContentReader: strings.NewReader("name: test\non:\n push:\n branches: [main]\n pull_request:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + }, + }, + ) + defer f() + + // a run has been created + assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID})) + + // add a file with a configured skip-ci string in commit message + addFileResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "bar.txt", + ContentReader: strings.NewReader("bar"), + }, + }, + Message: fmt.Sprintf("%s add bar", setting.Actions.SkipWorkflowStrings[0]), + OldBranch: "main", + NewBranch: "main", + Author: &files_service.IdentityOptions{ + Name: user2.Name, + Email: user2.Email, + }, + Committer: &files_service.IdentityOptions{ + Name: user2.Name, + Email: user2.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + require.NoError(t, err) + assert.NotEmpty(t, addFileResp) + + // the commit message contains a configured skip-ci string, so there is still only 1 record + assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID})) + + // add file to new branch + addFileToBranchResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "test-skip-ci", + ContentReader: strings.NewReader("test-skip-ci"), + }, + }, + Message: "add test file", + OldBranch: "main", + NewBranch: "test-skip-ci", + Author: &files_service.IdentityOptions{ + Name: user2.Name, + Email: user2.Email, + }, + Committer: &files_service.IdentityOptions{ + Name: user2.Name, + Email: user2.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + require.NoError(t, err) + assert.NotEmpty(t, addFileToBranchResp) + + resp := testPullCreate(t, session, "user2", "skip-ci", true, "main", "test-skip-ci", "[skip ci] test-skip-ci") + + // check the redirected URL + url := test.RedirectURL(resp) + assert.Regexp(t, "^/user2/skip-ci/pulls/[0-9]*$", url) + + // the pr title contains a configured skip-ci string, so there is still only 1 record + assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID})) + }) +} + +func TestCreateDeleteRefEvent(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // create the repo + repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{ + Name: "create-delete-ref-event", + Description: "test create delete ref ci event", + AutoInit: true, + Gitignores: "Go", + License: "MIT", + Readme: "Default", + DefaultBranch: "main", + IsPrivate: false, + }) + require.NoError(t, err) + assert.NotEmpty(t, repo) + + // enable actions + err = repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, []repo_model.RepoUnit{{ + RepoID: repo.ID, + Type: unit_model.TypeActions, + }}, nil) + require.NoError(t, err) + + // add workflow file to the repo + addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".gitea/workflows/createdelete.yml", + ContentReader: strings.NewReader("name: test\non:\n [create,delete]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + }, + }, + Message: "add workflow", + OldBranch: "main", + NewBranch: "main", + Author: &files_service.IdentityOptions{ + Name: user2.Name, + Email: user2.Email, + }, + Committer: &files_service.IdentityOptions{ + Name: user2.Name, + Email: user2.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + require.NoError(t, err) + assert.NotEmpty(t, addWorkflowToBaseResp) + + // Get the commit ID of the default branch + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo) + require.NoError(t, err) + defer gitRepo.Close() + branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch) + require.NoError(t, err) + + // create a branch + err = repo_service.CreateNewBranchFromCommit(db.DefaultContext, user2, repo, gitRepo, branch.CommitID, "test-create-branch") + require.NoError(t, err) + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ + Title: "add workflow", + RepoID: repo.ID, + Event: "create", + Ref: "refs/heads/test-create-branch", + WorkflowID: "createdelete.yml", + CommitSHA: branch.CommitID, + }) + assert.NotNil(t, run) + + // create a tag + err = release_service.CreateNewTag(db.DefaultContext, user2, repo, branch.CommitID, "test-create-tag", "test create tag event") + require.NoError(t, err) + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ + Title: "add workflow", + RepoID: repo.ID, + Event: "create", + Ref: "refs/tags/test-create-tag", + WorkflowID: "createdelete.yml", + CommitSHA: branch.CommitID, + }) + assert.NotNil(t, run) + + // delete the branch + err = repo_service.DeleteBranch(db.DefaultContext, user2, repo, gitRepo, "test-create-branch") + require.NoError(t, err) + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ + Title: "add workflow", + RepoID: repo.ID, + Event: "delete", + Ref: "refs/heads/main", + WorkflowID: "createdelete.yml", + CommitSHA: branch.CommitID, + }) + assert.NotNil(t, run) + + // delete the tag + tag, err := repo_model.GetRelease(db.DefaultContext, repo.ID, "test-create-tag") + require.NoError(t, err) + err = release_service.DeleteReleaseByID(db.DefaultContext, repo, tag, user2, true) + require.NoError(t, err) + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ + Title: "add workflow", + RepoID: repo.ID, + Event: "delete", + Ref: "refs/heads/main", + WorkflowID: "createdelete.yml", + CommitSHA: branch.CommitID, + }) + assert.NotNil(t, run) + }) +} + +func TestWorkflowDispatchEvent(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // create the repo + repo, sha, f := tests.CreateDeclarativeRepo(t, user2, "repo-workflow-dispatch", + []unit_model.Type{unit_model.TypeActions}, nil, + []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".gitea/workflows/dispatch.yml", + ContentReader: strings.NewReader( + "name: test\n" + + "on: [workflow_dispatch]\n" + + "jobs:\n" + + " test:\n" + + " runs-on: ubuntu-latest\n" + + " steps:\n" + + " - run: echo helloworld\n", + ), + }, + }, + ) + defer f() + + gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo) + require.NoError(t, err) + defer gitRepo.Close() + + workflow, err := actions_service.GetWorkflowFromCommit(gitRepo, "main", "dispatch.yml") + require.NoError(t, err) + assert.Equal(t, "refs/heads/main", workflow.Ref) + assert.Equal(t, sha, workflow.Commit.ID.String()) + + inputGetter := func(key string) string { + return "" + } + + err = workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2) + require.NoError(t, err) + + assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID})) + }) +} diff --git a/tests/integration/admin_config_test.go b/tests/integration/admin_config_test.go new file mode 100644 index 0000000..860a92d --- /dev/null +++ b/tests/integration/admin_config_test.go @@ -0,0 +1,23 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAdminConfig(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + req := NewRequest(t, "GET", "/admin/config") + resp := session.MakeRequest(t, req, http.StatusOK) + assert.True(t, test.IsNormalPageCompleted(resp.Body.String())) +} diff --git a/tests/integration/admin_user_test.go b/tests/integration/admin_user_test.go new file mode 100644 index 0000000..8cdaac3 --- /dev/null +++ b/tests/integration/admin_user_test.go @@ -0,0 +1,91 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "strconv" + "testing" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAdminViewUsers(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + req := NewRequest(t, "GET", "/admin/users") + session.MakeRequest(t, req, http.StatusOK) + + session = loginUser(t, "user2") + req = NewRequest(t, "GET", "/admin/users") + session.MakeRequest(t, req, http.StatusForbidden) +} + +func TestAdminViewUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + req := NewRequest(t, "GET", "/admin/users/1") + session.MakeRequest(t, req, http.StatusOK) + + session = loginUser(t, "user2") + req = NewRequest(t, "GET", "/admin/users/1") + session.MakeRequest(t, req, http.StatusForbidden) +} + +func TestAdminEditUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + testSuccessfullEdit(t, user_model.User{ID: 2, Name: "newusername", LoginName: "otherlogin", Email: "new@e-mail.gitea"}) +} + +func testSuccessfullEdit(t *testing.T, formData user_model.User) { + makeRequest(t, formData, http.StatusSeeOther) +} + +func makeRequest(t *testing.T, formData user_model.User, headerCode int) { + session := loginUser(t, "user1") + csrf := GetCSRF(t, session, "/admin/users/"+strconv.Itoa(int(formData.ID))+"/edit") + req := NewRequestWithValues(t, "POST", "/admin/users/"+strconv.Itoa(int(formData.ID))+"/edit", map[string]string{ + "_csrf": csrf, + "user_name": formData.Name, + "login_name": formData.LoginName, + "login_type": "0-0", + "email": formData.Email, + }) + + session.MakeRequest(t, req, headerCode) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: formData.ID}) + assert.Equal(t, formData.Name, user.Name) + assert.Equal(t, formData.LoginName, user.LoginName) + assert.Equal(t, formData.Email, user.Email) +} + +func TestAdminDeleteUser(t *testing.T) { + defer tests.AddFixtures("tests/integration/fixtures/TestAdminDeleteUser/")() + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + + userID := int64(1000) + + unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{PosterID: userID}) + + csrf := GetCSRF(t, session, fmt.Sprintf("/admin/users/%d/edit", userID)) + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/admin/users/%d/delete", userID), map[string]string{ + "_csrf": csrf, + "purge": "true", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + assertUserDeleted(t, userID, true) + unittest.CheckConsistencyFor(t, &user_model.User{}) +} diff --git a/tests/integration/api_actions_artifact_test.go b/tests/integration/api_actions_artifact_test.go new file mode 100644 index 0000000..2798024 --- /dev/null +++ b/tests/integration/api_actions_artifact_test.go @@ -0,0 +1,378 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "strings" + "testing" + + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +type uploadArtifactResponse struct { + FileContainerResourceURL string `json:"fileContainerResourceUrl"` +} + +type getUploadArtifactRequest struct { + Type string + Name string + RetentionDays int64 +} + +func TestActionsArtifactUploadSingleFile(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // acquire artifact upload url + req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{ + Type: "actions_storage", + Name: "artifact", + }).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp := MakeRequest(t, req, http.StatusOK) + var uploadResp uploadArtifactResponse + DecodeJSON(t, resp, &uploadResp) + assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts") + + // get upload url + idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/") + url := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=artifact/abc.txt" + + // upload artifact chunk + body := strings.Repeat("A", 1024) + req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a"). + SetHeader("Content-Range", "bytes 0-1023/1024"). + SetHeader("x-tfs-filelength", "1024"). + SetHeader("x-actions-results-md5", "1HsSe8LeLWh93ILaw1TEFQ==") // base64(md5(body)) + MakeRequest(t, req, http.StatusOK) + + t.Logf("Create artifact confirm") + + // confirm artifact upload + req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts?artifactName=artifact"). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + MakeRequest(t, req, http.StatusOK) +} + +func TestActionsArtifactUploadInvalidHash(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // artifact id 54321 not exist + url := "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts/8e5b948a454515dbabfc7eb718ddddddd/upload?itemPath=artifact/abc.txt" + body := strings.Repeat("A", 1024) + req := NewRequestWithBody(t, "PUT", url, strings.NewReader(body)). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a"). + SetHeader("Content-Range", "bytes 0-1023/1024"). + SetHeader("x-tfs-filelength", "1024"). + SetHeader("x-actions-results-md5", "1HsSe8LeLWh93ILaw1TEFQ==") // base64(md5(body)) + resp := MakeRequest(t, req, http.StatusBadRequest) + assert.Contains(t, resp.Body.String(), "Invalid artifact hash") +} + +func TestActionsArtifactConfirmUploadWithoutName(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts"). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp := MakeRequest(t, req, http.StatusBadRequest) + assert.Contains(t, resp.Body.String(), "artifact name is empty") +} + +func TestActionsArtifactUploadWithoutToken(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/1/artifacts", nil) + MakeRequest(t, req, http.StatusUnauthorized) +} + +type ( + listArtifactsResponseItem struct { + Name string `json:"name"` + FileContainerResourceURL string `json:"fileContainerResourceUrl"` + } + listArtifactsResponse struct { + Count int64 `json:"count"` + Value []listArtifactsResponseItem `json:"value"` + } + downloadArtifactResponseItem struct { + Path string `json:"path"` + ItemType string `json:"itemType"` + ContentLocation string `json:"contentLocation"` + } + downloadArtifactResponse struct { + Value []downloadArtifactResponseItem `json:"value"` + } +) + +func TestActionsArtifactDownload(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts"). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp := MakeRequest(t, req, http.StatusOK) + var listResp listArtifactsResponse + DecodeJSON(t, resp, &listResp) + assert.Equal(t, int64(1), listResp.Count) + assert.Equal(t, "artifact", listResp.Value[0].Name) + assert.Contains(t, listResp.Value[0].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts") + + idx := strings.Index(listResp.Value[0].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/") + url := listResp.Value[0].FileContainerResourceURL[idx+1:] + "?itemPath=artifact" + req = NewRequest(t, "GET", url). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp = MakeRequest(t, req, http.StatusOK) + var downloadResp downloadArtifactResponse + DecodeJSON(t, resp, &downloadResp) + assert.Len(t, downloadResp.Value, 1) + assert.Equal(t, "artifact/abc.txt", downloadResp.Value[0].Path) + assert.Equal(t, "file", downloadResp.Value[0].ItemType) + assert.Contains(t, downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts") + + idx = strings.Index(downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/") + url = downloadResp.Value[0].ContentLocation[idx:] + req = NewRequest(t, "GET", url). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp = MakeRequest(t, req, http.StatusOK) + body := strings.Repeat("A", 1024) + assert.Equal(t, resp.Body.String(), body) +} + +func TestActionsArtifactUploadMultipleFile(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + const testArtifactName = "multi-files" + + // acquire artifact upload url + req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{ + Type: "actions_storage", + Name: testArtifactName, + }).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp := MakeRequest(t, req, http.StatusOK) + var uploadResp uploadArtifactResponse + DecodeJSON(t, resp, &uploadResp) + assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts") + + type uploadingFile struct { + Path string + Content string + MD5 string + } + + files := []uploadingFile{ + { + Path: "abc.txt", + Content: strings.Repeat("A", 1024), + MD5: "1HsSe8LeLWh93ILaw1TEFQ==", + }, + { + Path: "xyz/def.txt", + Content: strings.Repeat("B", 1024), + MD5: "6fgADK/7zjadf+6cB9Q1CQ==", + }, + } + + for _, f := range files { + // get upload url + idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/") + url := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=" + testArtifactName + "/" + f.Path + + // upload artifact chunk + req = NewRequestWithBody(t, "PUT", url, strings.NewReader(f.Content)). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a"). + SetHeader("Content-Range", "bytes 0-1023/1024"). + SetHeader("x-tfs-filelength", "1024"). + SetHeader("x-actions-results-md5", f.MD5) // base64(md5(body)) + MakeRequest(t, req, http.StatusOK) + } + + t.Logf("Create artifact confirm") + + // confirm artifact upload + req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts?artifactName="+testArtifactName). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + MakeRequest(t, req, http.StatusOK) +} + +func TestActionsArtifactDownloadMultiFiles(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + const testArtifactName = "multi-files" + + req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts"). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp := MakeRequest(t, req, http.StatusOK) + var listResp listArtifactsResponse + DecodeJSON(t, resp, &listResp) + assert.Equal(t, int64(2), listResp.Count) + + var fileContainerResourceURL string + for _, v := range listResp.Value { + if v.Name == testArtifactName { + fileContainerResourceURL = v.FileContainerResourceURL + break + } + } + assert.Contains(t, fileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts") + + idx := strings.Index(fileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/") + url := fileContainerResourceURL[idx+1:] + "?itemPath=" + testArtifactName + req = NewRequest(t, "GET", url). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp = MakeRequest(t, req, http.StatusOK) + var downloadResp downloadArtifactResponse + DecodeJSON(t, resp, &downloadResp) + assert.Len(t, downloadResp.Value, 2) + + downloads := [][]string{{"multi-files/abc.txt", "A"}, {"multi-files/xyz/def.txt", "B"}} + for _, v := range downloadResp.Value { + var bodyChar string + var path string + for _, d := range downloads { + if v.Path == d[0] { + path = d[0] + bodyChar = d[1] + break + } + } + value := v + assert.Equal(t, path, value.Path) + assert.Equal(t, "file", value.ItemType) + assert.Contains(t, value.ContentLocation, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts") + + idx = strings.Index(value.ContentLocation, "/api/actions_pipeline/_apis/pipelines/") + url = value.ContentLocation[idx:] + req = NewRequest(t, "GET", url). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp = MakeRequest(t, req, http.StatusOK) + body := strings.Repeat(bodyChar, 1024) + assert.Equal(t, resp.Body.String(), body) + } +} + +func TestActionsArtifactUploadWithRetentionDays(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // acquire artifact upload url + req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{ + Type: "actions_storage", + Name: "artifact-retention-days", + RetentionDays: 9, + }).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp := MakeRequest(t, req, http.StatusOK) + var uploadResp uploadArtifactResponse + DecodeJSON(t, resp, &uploadResp) + assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts") + assert.Contains(t, uploadResp.FileContainerResourceURL, "?retentionDays=9") + + // get upload url + idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/") + url := uploadResp.FileContainerResourceURL[idx:] + "&itemPath=artifact-retention-days/abc.txt" + + // upload artifact chunk + body := strings.Repeat("A", 1024) + req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a"). + SetHeader("Content-Range", "bytes 0-1023/1024"). + SetHeader("x-tfs-filelength", "1024"). + SetHeader("x-actions-results-md5", "1HsSe8LeLWh93ILaw1TEFQ==") // base64(md5(body)) + MakeRequest(t, req, http.StatusOK) + + t.Logf("Create artifact confirm") + + // confirm artifact upload + req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts?artifactName=artifact-retention-days"). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + MakeRequest(t, req, http.StatusOK) +} + +func TestActionsArtifactOverwrite(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + { + // download old artifact uploaded by tests above, it should 1024 A + req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts"). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp := MakeRequest(t, req, http.StatusOK) + var listResp listArtifactsResponse + DecodeJSON(t, resp, &listResp) + + idx := strings.Index(listResp.Value[0].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/") + url := listResp.Value[0].FileContainerResourceURL[idx+1:] + "?itemPath=artifact" + req = NewRequest(t, "GET", url). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp = MakeRequest(t, req, http.StatusOK) + var downloadResp downloadArtifactResponse + DecodeJSON(t, resp, &downloadResp) + + idx = strings.Index(downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/") + url = downloadResp.Value[0].ContentLocation[idx:] + req = NewRequest(t, "GET", url). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp = MakeRequest(t, req, http.StatusOK) + body := strings.Repeat("A", 1024) + assert.Equal(t, resp.Body.String(), body) + } + + { + // upload same artifact, it uses 4096 B + req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{ + Type: "actions_storage", + Name: "artifact", + }).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp := MakeRequest(t, req, http.StatusOK) + var uploadResp uploadArtifactResponse + DecodeJSON(t, resp, &uploadResp) + + idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/") + url := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=artifact/abc.txt" + body := strings.Repeat("B", 4096) + req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a"). + SetHeader("Content-Range", "bytes 0-4095/4096"). + SetHeader("x-tfs-filelength", "4096"). + SetHeader("x-actions-results-md5", "wUypcJFeZCK5T6r4lfqzqg==") // base64(md5(body)) + MakeRequest(t, req, http.StatusOK) + + // confirm artifact upload + req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts?artifactName=artifact"). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + MakeRequest(t, req, http.StatusOK) + } + + { + // download artifact again, it should 4096 B + req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts"). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp := MakeRequest(t, req, http.StatusOK) + var listResp listArtifactsResponse + DecodeJSON(t, resp, &listResp) + + var uploadedItem listArtifactsResponseItem + for _, item := range listResp.Value { + if item.Name == "artifact" { + uploadedItem = item + break + } + } + assert.Equal(t, "artifact", uploadedItem.Name) + + idx := strings.Index(uploadedItem.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/") + url := uploadedItem.FileContainerResourceURL[idx+1:] + "?itemPath=artifact" + req = NewRequest(t, "GET", url). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp = MakeRequest(t, req, http.StatusOK) + var downloadResp downloadArtifactResponse + DecodeJSON(t, resp, &downloadResp) + + idx = strings.Index(downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/") + url = downloadResp.Value[0].ContentLocation[idx:] + req = NewRequest(t, "GET", url). + AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a") + resp = MakeRequest(t, req, http.StatusOK) + body := strings.Repeat("B", 4096) + assert.Equal(t, resp.Body.String(), body) + } +} diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go new file mode 100644 index 0000000..f55250f --- /dev/null +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -0,0 +1,404 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "io" + "net/http" + "strings" + "testing" + "time" + + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/routers/api/actions" + actions_service "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/known/timestamppb" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +func toProtoJSON(m protoreflect.ProtoMessage) io.Reader { + resp, _ := protojson.Marshal(m) + buf := bytes.Buffer{} + buf.Write(resp) + return &buf +} + +func uploadArtifact(t *testing.T, body string) string { + token, err := actions_service.CreateAuthorizationToken(48, 792, 193) + require.NoError(t, err) + + // acquire artifact upload url + req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{ + Version: 4, + Name: "artifact", + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var uploadResp actions.CreateArtifactResponse + protojson.Unmarshal(resp.Body.Bytes(), &uploadResp) + assert.True(t, uploadResp.Ok) + assert.Contains(t, uploadResp.SignedUploadUrl, "/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact") + + // get upload url + idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/") + url := uploadResp.SignedUploadUrl[idx:] + "&comp=block" + + // upload artifact chunk + req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)) + MakeRequest(t, req, http.StatusCreated) + + t.Logf("Create artifact confirm") + + sha := sha256.Sum256([]byte(body)) + + // confirm artifact upload + req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact", toProtoJSON(&actions.FinalizeArtifactRequest{ + Name: "artifact", + Size: 1024, + Hash: wrapperspb.String("sha256:" + hex.EncodeToString(sha[:])), + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var finalizeResp actions.FinalizeArtifactResponse + protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp) + assert.True(t, finalizeResp.Ok) + return token +} + +func TestActionsArtifactV4UploadSingleFile(t *testing.T) { + defer tests.PrepareTestEnv(t)() + body := strings.Repeat("A", 1024) + uploadArtifact(t, body) +} + +func TestActionsArtifactV4UploadSingleFileWrongChecksum(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token, err := actions_service.CreateAuthorizationToken(48, 792, 193) + require.NoError(t, err) + + // acquire artifact upload url + req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{ + Version: 4, + Name: "artifact-invalid-checksum", + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var uploadResp actions.CreateArtifactResponse + protojson.Unmarshal(resp.Body.Bytes(), &uploadResp) + assert.True(t, uploadResp.Ok) + assert.Contains(t, uploadResp.SignedUploadUrl, "/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact") + + // get upload url + idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/") + url := uploadResp.SignedUploadUrl[idx:] + "&comp=block" + + // upload artifact chunk + body := strings.Repeat("B", 1024) + req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)) + MakeRequest(t, req, http.StatusCreated) + + t.Logf("Create artifact confirm") + + sha := sha256.Sum256([]byte(strings.Repeat("A", 1024))) + + // confirm artifact upload + req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact", toProtoJSON(&actions.FinalizeArtifactRequest{ + Name: "artifact-invalid-checksum", + Size: 1024, + Hash: wrapperspb.String("sha256:" + hex.EncodeToString(sha[:])), + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusInternalServerError) +} + +func TestActionsArtifactV4UploadSingleFileWithRetentionDays(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token, err := actions_service.CreateAuthorizationToken(48, 792, 193) + require.NoError(t, err) + + // acquire artifact upload url + req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{ + Version: 4, + ExpiresAt: timestamppb.New(time.Now().Add(5 * 24 * time.Hour)), + Name: "artifactWithRetentionDays", + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var uploadResp actions.CreateArtifactResponse + protojson.Unmarshal(resp.Body.Bytes(), &uploadResp) + assert.True(t, uploadResp.Ok) + assert.Contains(t, uploadResp.SignedUploadUrl, "/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact") + + // get upload url + idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/") + url := uploadResp.SignedUploadUrl[idx:] + "&comp=block" + + // upload artifact chunk + body := strings.Repeat("A", 1024) + req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)) + MakeRequest(t, req, http.StatusCreated) + + t.Logf("Create artifact confirm") + + sha := sha256.Sum256([]byte(body)) + + // confirm artifact upload + req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact", toProtoJSON(&actions.FinalizeArtifactRequest{ + Name: "artifactWithRetentionDays", + Size: 1024, + Hash: wrapperspb.String("sha256:" + hex.EncodeToString(sha[:])), + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var finalizeResp actions.FinalizeArtifactResponse + protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp) + assert.True(t, finalizeResp.Ok) +} + +func TestActionsArtifactV4UploadSingleFileWithPotentialHarmfulBlockID(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token, err := actions_service.CreateAuthorizationToken(48, 792, 193) + require.NoError(t, err) + + // acquire artifact upload url + req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{ + Version: 4, + Name: "artifactWithPotentialHarmfulBlockID", + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var uploadResp actions.CreateArtifactResponse + protojson.Unmarshal(resp.Body.Bytes(), &uploadResp) + assert.True(t, uploadResp.Ok) + assert.Contains(t, uploadResp.SignedUploadUrl, "/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact") + + // get upload urls + idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/") + url := uploadResp.SignedUploadUrl[idx:] + "&comp=block&blockid=%2f..%2fmyfile" + blockListURL := uploadResp.SignedUploadUrl[idx:] + "&comp=blocklist" + + // upload artifact chunk + body := strings.Repeat("A", 1024) + req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)) + MakeRequest(t, req, http.StatusCreated) + + // verify that the exploit didn't work + _, err = storage.Actions.Stat("myfile") + require.Error(t, err) + + // upload artifact blockList + blockList := &actions.BlockList{ + Latest: []string{ + "/../myfile", + }, + } + rawBlockList, err := xml.Marshal(blockList) + require.NoError(t, err) + req = NewRequestWithBody(t, "PUT", blockListURL, bytes.NewReader(rawBlockList)) + MakeRequest(t, req, http.StatusCreated) + + t.Logf("Create artifact confirm") + + sha := sha256.Sum256([]byte(body)) + + // confirm artifact upload + req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact", toProtoJSON(&actions.FinalizeArtifactRequest{ + Name: "artifactWithPotentialHarmfulBlockID", + Size: 1024, + Hash: wrapperspb.String("sha256:" + hex.EncodeToString(sha[:])), + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var finalizeResp actions.FinalizeArtifactResponse + protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp) + assert.True(t, finalizeResp.Ok) +} + +func TestActionsArtifactV4UploadSingleFileWithChunksOutOfOrder(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token, err := actions_service.CreateAuthorizationToken(48, 792, 193) + require.NoError(t, err) + + // acquire artifact upload url + req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{ + Version: 4, + Name: "artifactWithChunksOutOfOrder", + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var uploadResp actions.CreateArtifactResponse + protojson.Unmarshal(resp.Body.Bytes(), &uploadResp) + assert.True(t, uploadResp.Ok) + assert.Contains(t, uploadResp.SignedUploadUrl, "/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact") + + // get upload urls + idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/") + block1URL := uploadResp.SignedUploadUrl[idx:] + "&comp=block&blockid=block1" + block2URL := uploadResp.SignedUploadUrl[idx:] + "&comp=block&blockid=block2" + blockListURL := uploadResp.SignedUploadUrl[idx:] + "&comp=blocklist" + + // upload artifact chunks + bodyb := strings.Repeat("B", 1024) + req = NewRequestWithBody(t, "PUT", block2URL, strings.NewReader(bodyb)) + MakeRequest(t, req, http.StatusCreated) + + bodya := strings.Repeat("A", 1024) + req = NewRequestWithBody(t, "PUT", block1URL, strings.NewReader(bodya)) + MakeRequest(t, req, http.StatusCreated) + + // upload artifact blockList + blockList := &actions.BlockList{ + Latest: []string{ + "block1", + "block2", + }, + } + rawBlockList, err := xml.Marshal(blockList) + require.NoError(t, err) + req = NewRequestWithBody(t, "PUT", blockListURL, bytes.NewReader(rawBlockList)) + MakeRequest(t, req, http.StatusCreated) + + t.Logf("Create artifact confirm") + + sha := sha256.Sum256([]byte(bodya + bodyb)) + + // confirm artifact upload + req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact", toProtoJSON(&actions.FinalizeArtifactRequest{ + Name: "artifactWithChunksOutOfOrder", + Size: 2048, + Hash: wrapperspb.String("sha256:" + hex.EncodeToString(sha[:])), + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var finalizeResp actions.FinalizeArtifactResponse + protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp) + assert.True(t, finalizeResp.Ok) +} + +func TestActionsArtifactV4DownloadSingle(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token, err := actions_service.CreateAuthorizationToken(48, 792, 193) + require.NoError(t, err) + + // acquire artifact upload url + req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts", toProtoJSON(&actions.ListArtifactsRequest{ + NameFilter: wrapperspb.String("artifact"), + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var listResp actions.ListArtifactsResponse + protojson.Unmarshal(resp.Body.Bytes(), &listResp) + assert.Len(t, listResp.Artifacts, 1) + + // confirm artifact upload + req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL", toProtoJSON(&actions.GetSignedArtifactURLRequest{ + Name: "artifact", + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var finalizeResp actions.GetSignedArtifactURLResponse + protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp) + assert.NotEmpty(t, finalizeResp.SignedUrl) + + req = NewRequest(t, "GET", finalizeResp.SignedUrl) + resp = MakeRequest(t, req, http.StatusOK) + body := strings.Repeat("A", 1024) + assert.Equal(t, "bytes", resp.Header().Get("accept-ranges")) + assert.Equal(t, body, resp.Body.String()) + + // Download artifact via user-facing URL + req = NewRequest(t, "GET", "/user5/repo4/actions/runs/188/artifacts/artifact") + resp = MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "bytes", resp.Header().Get("accept-ranges")) + assert.Equal(t, body, resp.Body.String()) + + // Partial artifact download + req = NewRequest(t, "GET", "/user5/repo4/actions/runs/188/artifacts/artifact").SetHeader("range", "bytes=0-99") + resp = MakeRequest(t, req, http.StatusPartialContent) + body = strings.Repeat("A", 100) + assert.Equal(t, "bytes 0-99/1024", resp.Header().Get("content-range")) + assert.Equal(t, body, resp.Body.String()) +} + +func TestActionsArtifactV4DownloadRange(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + bstr := strings.Repeat("B", 100) + body := strings.Repeat("A", 100) + bstr + token := uploadArtifact(t, body) + + // Download (Actions API) + req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL", toProtoJSON(&actions.GetSignedArtifactURLRequest{ + Name: "artifact", + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var finalizeResp actions.GetSignedArtifactURLResponse + protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp) + assert.NotEmpty(t, finalizeResp.SignedUrl) + + req = NewRequest(t, "GET", finalizeResp.SignedUrl).SetHeader("range", "bytes=100-199") + resp = MakeRequest(t, req, http.StatusPartialContent) + assert.Equal(t, "bytes 100-199/200", resp.Header().Get("content-range")) + assert.Equal(t, bstr, resp.Body.String()) + + // Download (user-facing API) + req = NewRequest(t, "GET", "/user5/repo4/actions/runs/188/artifacts/artifact").SetHeader("range", "bytes=100-199") + resp = MakeRequest(t, req, http.StatusPartialContent) + assert.Equal(t, "bytes 100-199/200", resp.Header().Get("content-range")) + assert.Equal(t, bstr, resp.Body.String()) +} + +func TestActionsArtifactV4Delete(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token, err := actions_service.CreateAuthorizationToken(48, 792, 193) + require.NoError(t, err) + + // delete artifact by name + req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/DeleteArtifact", toProtoJSON(&actions.DeleteArtifactRequest{ + Name: "artifact", + WorkflowRunBackendId: "792", + WorkflowJobRunBackendId: "193", + })).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var deleteResp actions.DeleteArtifactResponse + protojson.Unmarshal(resp.Body.Bytes(), &deleteResp) + assert.True(t, deleteResp.Ok) +} diff --git a/tests/integration/api_activitypub_actor_test.go b/tests/integration/api_activitypub_actor_test.go new file mode 100644 index 0000000..7506c78 --- /dev/null +++ b/tests/integration/api_activitypub_actor_test.go @@ -0,0 +1,50 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + + ap "github.com/go-ap/activitypub" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActivityPubActor(t *testing.T) { + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + onGiteaRun(t, func(*testing.T, *url.URL) { + req := NewRequest(t, "GET", "/api/v1/activitypub/actor") + resp := MakeRequest(t, req, http.StatusOK) + body := resp.Body.Bytes() + assert.Contains(t, string(body), "@context") + + var actor ap.Actor + err := actor.UnmarshalJSON(body) + require.NoError(t, err) + + assert.Equal(t, ap.ApplicationType, actor.Type) + assert.Equal(t, setting.Domain, actor.PreferredUsername.String()) + keyID := actor.GetID().String() + assert.Regexp(t, "activitypub/actor$", keyID) + assert.Regexp(t, "activitypub/actor/outbox$", actor.Outbox.GetID().String()) + assert.Regexp(t, "activitypub/actor/inbox$", actor.Inbox.GetID().String()) + + pubKey := actor.PublicKey + assert.NotNil(t, pubKey) + publicKeyID := keyID + "#main-key" + assert.Equal(t, pubKey.ID.String(), publicKeyID) + + pubKeyPem := pubKey.PublicKeyPem + assert.NotNil(t, pubKeyPem) + assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem) + }) +} diff --git a/tests/integration/api_activitypub_person_test.go b/tests/integration/api_activitypub_person_test.go new file mode 100644 index 0000000..55935e4 --- /dev/null +++ b/tests/integration/api_activitypub_person_test.go @@ -0,0 +1,116 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/activitypub" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers" + + ap "github.com/go-ap/activitypub" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActivityPubPerson(t *testing.T) { + setting.Federation.Enabled = true + testWebRoutes = routers.NormalRoutes() + defer func() { + setting.Federation.Enabled = false + testWebRoutes = routers.NormalRoutes() + }() + + onGiteaRun(t, func(*testing.T, *url.URL) { + userID := 2 + username := "user2" + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/user-id/%v", userID)) + resp := MakeRequest(t, req, http.StatusOK) + body := resp.Body.Bytes() + assert.Contains(t, string(body), "@context") + + var person ap.Person + err := person.UnmarshalJSON(body) + require.NoError(t, err) + + assert.Equal(t, ap.PersonType, person.Type) + assert.Equal(t, username, person.PreferredUsername.String()) + keyID := person.GetID().String() + assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%v$", userID), keyID) + assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%v/outbox$", userID), person.Outbox.GetID().String()) + assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%v/inbox$", userID), person.Inbox.GetID().String()) + + pubKey := person.PublicKey + assert.NotNil(t, pubKey) + publicKeyID := keyID + "#main-key" + assert.Equal(t, pubKey.ID.String(), publicKeyID) + + pubKeyPem := pubKey.PublicKeyPem + assert.NotNil(t, pubKeyPem) + assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem) + }) +} + +func TestActivityPubMissingPerson(t *testing.T) { + setting.Federation.Enabled = true + testWebRoutes = routers.NormalRoutes() + defer func() { + setting.Federation.Enabled = false + testWebRoutes = routers.NormalRoutes() + }() + + onGiteaRun(t, func(*testing.T, *url.URL) { + req := NewRequest(t, "GET", "/api/v1/activitypub/user-id/999999999") + resp := MakeRequest(t, req, http.StatusNotFound) + assert.Contains(t, resp.Body.String(), "user does not exist") + }) +} + +func TestActivityPubPersonInbox(t *testing.T) { + setting.Federation.Enabled = true + testWebRoutes = routers.NormalRoutes() + defer func() { + setting.Federation.Enabled = false + testWebRoutes = routers.NormalRoutes() + }() + + srv := httptest.NewServer(testWebRoutes) + defer srv.Close() + + onGiteaRun(t, func(*testing.T, *url.URL) { + appURL := setting.AppURL + setting.AppURL = srv.URL + "/" + defer func() { + setting.Database.LogSQL = false + setting.AppURL = appURL + }() + username1 := "user1" + ctx := context.Background() + user1, err := user_model.GetUserByName(ctx, username1) + require.NoError(t, err) + user1url := fmt.Sprintf("%s/api/v1/activitypub/user-id/1#main-key", srv.URL) + cf, err := activitypub.GetClientFactory(ctx) + require.NoError(t, err) + c, err := cf.WithKeys(db.DefaultContext, user1, user1url) + require.NoError(t, err) + user2inboxurl := fmt.Sprintf("%s/api/v1/activitypub/user-id/2/inbox", srv.URL) + + // Signed request succeeds + resp, err := c.Post([]byte{}, user2inboxurl) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + // Unsigned request fails + req := NewRequest(t, "POST", user2inboxurl) + MakeRequest(t, req, http.StatusBadRequest) + }) +} diff --git a/tests/integration/api_activitypub_repository_test.go b/tests/integration/api_activitypub_repository_test.go new file mode 100644 index 0000000..737f580 --- /dev/null +++ b/tests/integration/api_activitypub_repository_test.go @@ -0,0 +1,249 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/forgefed" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/activitypub" + forgefed_modules "code.gitea.io/gitea/modules/forgefed" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActivityPubRepository(t *testing.T) { + setting.Federation.Enabled = true + testWebRoutes = routers.NormalRoutes() + defer func() { + setting.Federation.Enabled = false + testWebRoutes = routers.NormalRoutes() + }() + + onGiteaRun(t, func(*testing.T, *url.URL) { + repositoryID := 2 + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%v", repositoryID)) + resp := MakeRequest(t, req, http.StatusOK) + body := resp.Body.Bytes() + assert.Contains(t, string(body), "@context") + + var repository forgefed_modules.Repository + err := repository.UnmarshalJSON(body) + require.NoError(t, err) + + assert.Regexp(t, fmt.Sprintf("activitypub/repository-id/%v$", repositoryID), repository.GetID().String()) + }) +} + +func TestActivityPubMissingRepository(t *testing.T) { + setting.Federation.Enabled = true + testWebRoutes = routers.NormalRoutes() + defer func() { + setting.Federation.Enabled = false + testWebRoutes = routers.NormalRoutes() + }() + + onGiteaRun(t, func(*testing.T, *url.URL) { + repositoryID := 9999999 + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%v", repositoryID)) + resp := MakeRequest(t, req, http.StatusNotFound) + assert.Contains(t, resp.Body.String(), "repository does not exist") + }) +} + +func TestActivityPubRepositoryInboxValid(t *testing.T) { + setting.Federation.Enabled = true + testWebRoutes = routers.NormalRoutes() + defer func() { + setting.Federation.Enabled = false + testWebRoutes = routers.NormalRoutes() + }() + + srv := httptest.NewServer(testWebRoutes) + defer srv.Close() + + federatedRoutes := http.NewServeMux() + federatedRoutes.HandleFunc("/.well-known/nodeinfo", + func(res http.ResponseWriter, req *http.Request) { + // curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/.well-known/nodeinfo + responseBody := fmt.Sprintf(`{"links":[{"href":"http://%s/api/v1/nodeinfo","rel":"http://nodeinfo.diaspora.software/ns/schema/2.1"}]}`, req.Host) + t.Logf("response: %s", responseBody) + // TODO: as soon as content-type will become important: content-type: application/json;charset=utf-8 + fmt.Fprint(res, responseBody) + }) + federatedRoutes.HandleFunc("/api/v1/nodeinfo", + func(res http.ResponseWriter, req *http.Request) { + // curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/nodeinfo + responseBody := fmt.Sprintf(`{"version":"2.1","software":{"name":"forgejo","version":"1.20.0+dev-3183-g976d79044",` + + `"repository":"https://codeberg.org/forgejo/forgejo.git","homepage":"https://forgejo.org/"},` + + `"protocols":["activitypub"],"services":{"inbound":[],"outbound":["rss2.0"]},` + + `"openRegistrations":true,"usage":{"users":{"total":14,"activeHalfyear":2}},"metadata":{}}`) + fmt.Fprint(res, responseBody) + }) + federatedRoutes.HandleFunc("/api/v1/activitypub/user-id/15", + func(res http.ResponseWriter, req *http.Request) { + // curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/2 + responseBody := fmt.Sprintf(`{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1"],` + + `"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/15","type":"Person",` + + `"icon":{"type":"Image","mediaType":"image/png","url":"https://federated-repo.prod.meissa.de/avatars/1bb05d9a5f6675ed0272af9ea193063c"},` + + `"url":"https://federated-repo.prod.meissa.de/stargoose1","inbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/15/inbox",` + + `"outbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/15/outbox","preferredUsername":"stargoose1",` + + `"publicKey":{"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/15#main-key","owner":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/15",` + + `"publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA18H5s7N6ItZUAh9tneII\nIuZdTTa3cZlLa/9ejWAHTkcp3WLW+/zbsumlMrWYfBy2/yTm56qasWt38iY4D6ul\n` + + `CPiwhAqX3REvVq8tM79a2CEqZn9ka6vuXoDgBg/sBf/BUWqf7orkjUXwk/U0Egjf\nk5jcurF4vqf1u+rlAHH37dvSBaDjNj6Qnj4OP12bjfaY/yvs7+jue/eNXFHjzN4E\n` + + `T2H4B/yeKTJ4UuAwTlLaNbZJul2baLlHelJPAsxiYaziVuV5P+IGWckY6RSerRaZ\nAkc4mmGGtjAyfN9aewe+lNVfwS7ElFx546PlLgdQgjmeSwLX8FWxbPE5A/PmaXCs\n` + + `nx+nou+3dD7NluULLtdd7K+2x02trObKXCAzmi5/Dc+yKTzpFqEz+hLNCz7TImP/\ncK//NV9Q+X67J9O27baH9R9ZF4zMw8rv2Pg0WLSw1z7lLXwlgIsDapeMCsrxkVO4\n` + + `LXX5AQ1xQNtlssnVoUBqBrvZsX2jUUKUocvZqMGuE4hfAgMBAAE=\n-----END PUBLIC KEY-----\n"}}`) + fmt.Fprint(res, responseBody) + }) + federatedRoutes.HandleFunc("/api/v1/activitypub/user-id/30", + func(res http.ResponseWriter, req *http.Request) { + // curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/3 + responseBody := fmt.Sprintf(`{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1"],` + + `"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/30","type":"Person",` + + `"icon":{"type":"Image","mediaType":"image/png","url":"https://federated-repo.prod.meissa.de/avatars/9c03f03d1c1f13f21976a22489326fe1"},` + + `"url":"https://federated-repo.prod.meissa.de/stargoose2","inbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/30/inbox",` + + `"outbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/30/outbox","preferredUsername":"stargoose2",` + + `"publicKey":{"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/30#main-key","owner":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/30",` + + `"publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAyv5NytsfqpWXSrwuk8a3\n0W1zE13QJioXb/e3opgN2CfKZkdm3hb+4+mGKoU/rCqegnL9/AO0Aw+R8fCHXx44\n` + + `iNkdVpdY8Dzq+tQ9IetPWbyVIBvSzGgvpqfS05JuVPsy8cBX9wByODjr5kq7k1/v\nY1G7E3uh0a/XJc+mZutwGC3gPgR93NSrqsvTPN4wdhCCu9uj02S8OBoKuSYaPkU+\n` + + `tZ4CEDpnclAOw/eNiH4x2irMvVtruEgtlTA5K2I4YJrmtGLidus47FCyc8/zEKUh\nAeiD8KWDvqsQgOhUwcQgRxAnYVCoMD9cnE+WFFRHTuQecNlmdNFs3Cr0yKcWjDde\n` + + `trvnehW7LfPveGb0tHRHPuVAJpncTOidUR5h/7pqMyvKHzuAHWomm9rEaGUxd/7a\nL1CFjAf39+QIEgu0Anj8mIc7CTiz+DQhDz+0jBOsQ0iDXc5GeBz7X9Xv4Jp966nq\n` + + `MUR0GQGXvfZQN9IqMO+WoUVy10Ddhns1EWGlA0x4fecnAgMBAAE=\n-----END PUBLIC KEY-----\n"}}`) + fmt.Fprint(res, responseBody) + }) + federatedRoutes.HandleFunc("/", + func(res http.ResponseWriter, req *http.Request) { + t.Errorf("Unhandled request: %q", req.URL.EscapedPath()) + }) + federatedSrv := httptest.NewServer(federatedRoutes) + defer federatedSrv.Close() + + onGiteaRun(t, func(*testing.T, *url.URL) { + appURL := setting.AppURL + setting.AppURL = srv.URL + "/" + defer func() { + setting.Database.LogSQL = false + setting.AppURL = appURL + }() + actionsUser := user.NewActionsUser() + repositoryID := 2 + cf, err := activitypub.GetClientFactory(db.DefaultContext) + require.NoError(t, err) + c, err := cf.WithKeys(db.DefaultContext, actionsUser, "not used") + require.NoError(t, err) + repoInboxURL := fmt.Sprintf( + "%s/api/v1/activitypub/repository-id/%v/inbox", + srv.URL, repositoryID) + + timeNow := time.Now().UTC() + + activity1 := []byte(fmt.Sprintf( + `{"type":"Like",`+ + `"startTime":"%s",`+ + `"actor":"%s/api/v1/activitypub/user-id/15",`+ + `"object":"%s/api/v1/activitypub/repository-id/%v"}`, + timeNow.Format(time.RFC3339), + federatedSrv.URL, srv.URL, repositoryID)) + t.Logf("activity: %s", activity1) + resp, err := c.Post(activity1, repoInboxURL) + + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + federationHost := unittest.AssertExistsAndLoadBean(t, &forgefed.FederationHost{HostFqdn: "127.0.0.1"}) + federatedUser := unittest.AssertExistsAndLoadBean(t, &user.FederatedUser{ExternalID: "15", FederationHostID: federationHost.ID}) + unittest.AssertExistsAndLoadBean(t, &user.User{ID: federatedUser.UserID}) + + // A like activity by a different user of the same federated host. + activity2 := []byte(fmt.Sprintf( + `{"type":"Like",`+ + `"startTime":"%s",`+ + `"actor":"%s/api/v1/activitypub/user-id/30",`+ + `"object":"%s/api/v1/activitypub/repository-id/%v"}`, + // Make sure this activity happens later then the one before + timeNow.Add(time.Second).Format(time.RFC3339), + federatedSrv.URL, srv.URL, repositoryID)) + t.Logf("activity: %s", activity2) + resp, err = c.Post(activity2, repoInboxURL) + + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + federatedUser = unittest.AssertExistsAndLoadBean(t, &user.FederatedUser{ExternalID: "30", FederationHostID: federationHost.ID}) + unittest.AssertExistsAndLoadBean(t, &user.User{ID: federatedUser.UserID}) + + // The same user sends another like activity + otherRepositoryID := 3 + otherRepoInboxURL := fmt.Sprintf( + "%s/api/v1/activitypub/repository-id/%v/inbox", + srv.URL, otherRepositoryID) + activity3 := []byte(fmt.Sprintf( + `{"type":"Like",`+ + `"startTime":"%s",`+ + `"actor":"%s/api/v1/activitypub/user-id/30",`+ + `"object":"%s/api/v1/activitypub/repository-id/%v"}`, + // Make sure this activity happens later then the ones before + timeNow.Add(time.Second*2).Format(time.RFC3339), + federatedSrv.URL, srv.URL, otherRepositoryID)) + t.Logf("activity: %s", activity3) + resp, err = c.Post(activity3, otherRepoInboxURL) + + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + federatedUser = unittest.AssertExistsAndLoadBean(t, &user.FederatedUser{ExternalID: "30", FederationHostID: federationHost.ID}) + unittest.AssertExistsAndLoadBean(t, &user.User{ID: federatedUser.UserID}) + + // Replay activity2. + resp, err = c.Post(activity2, repoInboxURL) + require.NoError(t, err) + assert.Equal(t, http.StatusNotAcceptable, resp.StatusCode) + }) +} + +func TestActivityPubRepositoryInboxInvalid(t *testing.T) { + setting.Federation.Enabled = true + testWebRoutes = routers.NormalRoutes() + defer func() { + setting.Federation.Enabled = false + testWebRoutes = routers.NormalRoutes() + }() + + srv := httptest.NewServer(testWebRoutes) + defer srv.Close() + + onGiteaRun(t, func(*testing.T, *url.URL) { + appURL := setting.AppURL + setting.AppURL = srv.URL + "/" + defer func() { + setting.Database.LogSQL = false + setting.AppURL = appURL + }() + actionsUser := user.NewActionsUser() + repositoryID := 2 + cf, err := activitypub.GetClientFactory(db.DefaultContext) + require.NoError(t, err) + c, err := cf.WithKeys(db.DefaultContext, actionsUser, "not used") + require.NoError(t, err) + repoInboxURL := fmt.Sprintf("%s/api/v1/activitypub/repository-id/%v/inbox", + srv.URL, repositoryID) + + activity := []byte(`{"type":"Wrong"}`) + resp, err := c.Post(activity, repoInboxURL) + require.NoError(t, err) + assert.Equal(t, http.StatusNotAcceptable, resp.StatusCode) + }) +} diff --git a/tests/integration/api_admin_org_test.go b/tests/integration/api_admin_org_test.go new file mode 100644 index 0000000..a29d0ba --- /dev/null +++ b/tests/integration/api_admin_org_test.go @@ -0,0 +1,91 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIAdminOrgCreate(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin) + + org := api.CreateOrgOption{ + UserName: "user2_org", + FullName: "User2's organization", + Description: "This organization created by admin for user2", + Website: "https://try.gitea.io", + Location: "Shanghai", + Visibility: "private", + } + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/users/user2/orgs", &org). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var apiOrg api.Organization + DecodeJSON(t, resp, &apiOrg) + + assert.Equal(t, org.UserName, apiOrg.Name) + assert.Equal(t, org.FullName, apiOrg.FullName) + assert.Equal(t, org.Description, apiOrg.Description) + assert.Equal(t, org.Website, apiOrg.Website) + assert.Equal(t, org.Location, apiOrg.Location) + assert.Equal(t, org.Visibility, apiOrg.Visibility) + + unittest.AssertExistsAndLoadBean(t, &user_model.User{ + Name: org.UserName, + LowerName: strings.ToLower(org.UserName), + FullName: org.FullName, + }) + }) +} + +func TestAPIAdminOrgCreateBadVisibility(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin) + + org := api.CreateOrgOption{ + UserName: "user2_org", + FullName: "User2's organization", + Description: "This organization created by admin for user2", + Website: "https://try.gitea.io", + Location: "Shanghai", + Visibility: "notvalid", + } + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/users/user2/orgs", &org). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + }) +} + +func TestAPIAdminOrgCreateNotAdmin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + nonAdminUsername := "user2" + session := loginUser(t, nonAdminUsername) + token := getTokenForLoggedInUser(t, session) + org := api.CreateOrgOption{ + UserName: "user2_org", + FullName: "User2's organization", + Description: "This organization created by admin for user2", + Website: "https://try.gitea.io", + Location: "Shanghai", + Visibility: "public", + } + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/users/user2/orgs", &org). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) +} diff --git a/tests/integration/api_admin_test.go b/tests/integration/api_admin_test.go new file mode 100644 index 0000000..5f8d360 --- /dev/null +++ b/tests/integration/api_admin_test.go @@ -0,0 +1,420 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + "time" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/gobwas/glob" + "github.com/stretchr/testify/assert" +) + +func TestAPIAdminCreateAndDeleteSSHKey(t *testing.T) { + defer tests.PrepareTestEnv(t)() + // user1 is an admin user + session := loginUser(t, "user1") + keyOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}) + + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin) + urlStr := fmt.Sprintf("/api/v1/admin/users/%s/keys", keyOwner.Name) + req := NewRequestWithValues(t, "POST", urlStr, map[string]string{ + "key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment\n", + "title": "test-key", + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var newPublicKey api.PublicKey + DecodeJSON(t, resp, &newPublicKey) + unittest.AssertExistsAndLoadBean(t, &asymkey_model.PublicKey{ + ID: newPublicKey.ID, + Name: newPublicKey.Title, + Fingerprint: newPublicKey.Fingerprint, + OwnerID: keyOwner.ID, + }) + + req = NewRequestf(t, "DELETE", "/api/v1/admin/users/%s/keys/%d", keyOwner.Name, newPublicKey.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + unittest.AssertNotExistsBean(t, &asymkey_model.PublicKey{ID: newPublicKey.ID}) +} + +func TestAPIAdminDeleteMissingSSHKey(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // user1 is an admin user + token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteAdmin) + req := NewRequestf(t, "DELETE", "/api/v1/admin/users/user1/keys/%d", unittest.NonexistentID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestAPIAdminDeleteUnauthorizedKey(t *testing.T) { + defer tests.PrepareTestEnv(t)() + adminUsername := "user1" + normalUsername := "user2" + token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin) + + urlStr := fmt.Sprintf("/api/v1/admin/users/%s/keys", adminUsername) + req := NewRequestWithValues(t, "POST", urlStr, map[string]string{ + "key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment\n", + "title": "test-key", + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var newPublicKey api.PublicKey + DecodeJSON(t, resp, &newPublicKey) + + token = getUserToken(t, normalUsername) + req = NewRequestf(t, "DELETE", "/api/v1/admin/users/%s/keys/%d", adminUsername, newPublicKey.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) +} + +func TestAPISudoUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + adminUsername := "user1" + normalUsername := "user2" + token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeReadUser) + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/user?sudo=%s", normalUsername)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var user api.User + DecodeJSON(t, resp, &user) + + assert.Equal(t, normalUsername, user.UserName) +} + +func TestAPISudoUserForbidden(t *testing.T) { + defer tests.PrepareTestEnv(t)() + adminUsername := "user1" + normalUsername := "user2" + + token := getUserToken(t, normalUsername, auth_model.AccessTokenScopeReadAdmin) + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/user?sudo=%s", adminUsername)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) +} + +func TestAPIListUsers(t *testing.T) { + defer tests.PrepareTestEnv(t)() + adminUsername := "user1" + token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeReadAdmin) + + req := NewRequest(t, "GET", "/api/v1/admin/users"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var users []api.User + DecodeJSON(t, resp, &users) + + found := false + for _, user := range users { + if user.UserName == adminUsername { + found = true + } + } + assert.True(t, found) + numberOfUsers := unittest.GetCount(t, &user_model.User{}, "type = 0") + assert.Len(t, users, numberOfUsers) +} + +func TestAPIListUsersNotLoggedIn(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequest(t, "GET", "/api/v1/admin/users") + MakeRequest(t, req, http.StatusUnauthorized) +} + +func TestAPIListUsersNonAdmin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + nonAdminUsername := "user2" + token := getUserToken(t, nonAdminUsername) + req := NewRequest(t, "GET", "/api/v1/admin/users"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) +} + +func TestAPICreateUserInvalidEmail(t *testing.T) { + defer tests.PrepareTestEnv(t)() + adminUsername := "user1" + token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin) + req := NewRequestWithValues(t, "POST", "/api/v1/admin/users", map[string]string{ + "email": "invalid_email@domain.com\r\n", + "full_name": "invalid user", + "login_name": "invalidUser", + "must_change_password": "true", + "password": "password", + "send_notify": "true", + "source_id": "0", + "username": "invalidUser", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) +} + +func TestAPICreateAndDeleteUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + adminUsername := "user1" + token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin) + + req := NewRequestWithValues( + t, + "POST", + "/api/v1/admin/users", + map[string]string{ + "email": "deleteme@domain.com", + "full_name": "delete me", + "login_name": "deleteme", + "must_change_password": "true", + "password": "password", + "send_notify": "true", + "source_id": "0", + "username": "deleteme", + }, + ).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequest(t, "DELETE", "/api/v1/admin/users/deleteme"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) +} + +func TestAPIEditUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + adminUsername := "user1" + token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin) + urlStr := fmt.Sprintf("/api/v1/admin/users/%s", "user2") + + fullNameToChange := "Full Name User 2" + req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{ + "full_name": fullNameToChange, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{LoginName: "user2"}) + assert.Equal(t, fullNameToChange, user2.FullName) + + empty := "" + req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{ + Email: &empty, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusBadRequest) + + errMap := make(map[string]any) + json.Unmarshal(resp.Body.Bytes(), &errMap) + assert.EqualValues(t, "e-mail invalid [email: ]", errMap["message"].(string)) + + user2 = unittest.AssertExistsAndLoadBean(t, &user_model.User{LoginName: "user2"}) + assert.False(t, user2.IsRestricted) + bTrue := true + req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{ + Restricted: &bTrue, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + user2 = unittest.AssertExistsAndLoadBean(t, &user_model.User{LoginName: "user2"}) + assert.True(t, user2.IsRestricted) +} + +func TestAPIEditUserWithLoginName(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + adminUsername := "user1" + token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin) + urlStr := fmt.Sprintf("/api/v1/admin/users/%s", "user2") + + loginName := "user2" + loginSource := int64(0) + + t.Run("login_name only", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{ + LoginName: &loginName, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + + t.Run("source_id only", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{ + SourceID: &loginSource, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + + t.Run("login_name & source_id", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{ + LoginName: &loginName, + SourceID: &loginSource, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + }) +} + +func TestAPICreateRepoForUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + adminUsername := "user1" + token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin) + + req := NewRequestWithJSON( + t, + "POST", + fmt.Sprintf("/api/v1/admin/users/%s/repos", adminUsername), + &api.CreateRepoOption{ + Name: "admincreatedrepo", + }, + ).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) +} + +func TestAPIRenameUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + adminUsername := "user1" + token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin) + urlStr := fmt.Sprintf("/api/v1/admin/users/%s/rename", "user2") + req := NewRequestWithValues(t, "POST", urlStr, map[string]string{ + // required + "new_name": "User2", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + urlStr = fmt.Sprintf("/api/v1/admin/users/%s/rename", "User2") + req = NewRequestWithValues(t, "POST", urlStr, map[string]string{ + // required + "new_name": "User2-2-2", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequestWithValues(t, "POST", urlStr, map[string]string{ + // required + "new_name": "user1", + }).AddTokenAuth(token) + // the old user name still be used by with a redirect + MakeRequest(t, req, http.StatusTemporaryRedirect) + + urlStr = fmt.Sprintf("/api/v1/admin/users/%s/rename", "User2-2-2") + req = NewRequestWithValues(t, "POST", urlStr, map[string]string{ + // required + "new_name": "user1", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + req = NewRequestWithValues(t, "POST", urlStr, map[string]string{ + // required + "new_name": "user2", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) +} + +func TestAPICron(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // user1 is an admin user + session := loginUser(t, "user1") + + t.Run("List", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadAdmin) + + req := NewRequest(t, "GET", "/api/v1/admin/cron"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "28", resp.Header().Get("X-Total-Count")) + + var crons []api.Cron + DecodeJSON(t, resp, &crons) + assert.Len(t, crons, 28) + }) + + t.Run("Execute", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + now := time.Now() + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin) + // Archive cleanup is harmless, because in the test environment there are none + // and is thus an NOOP operation and therefore doesn't interfere with any other + // tests. + req := NewRequest(t, "POST", "/api/v1/admin/cron/archive_cleanup"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // Check for the latest run time for this cron, to ensure it has been run. + req = NewRequest(t, "GET", "/api/v1/admin/cron"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var crons []api.Cron + DecodeJSON(t, resp, &crons) + + for _, cron := range crons { + if cron.Name == "archive_cleanup" { + assert.True(t, now.Before(cron.Prev)) + } + } + }) +} + +func TestAPICreateUser_NotAllowedEmailDomain(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + setting.Service.EmailDomainAllowList = []glob.Glob{glob.MustCompile("example.org")} + defer func() { + setting.Service.EmailDomainAllowList = []glob.Glob{} + }() + + adminUsername := "user1" + token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin) + + req := NewRequestWithValues(t, "POST", "/api/v1/admin/users", map[string]string{ + "email": "allowedUser1@example1.org", + "login_name": "allowedUser1", + "username": "allowedUser1", + "password": "allowedUser1_pass", + "must_change_password": "true", + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + assert.Equal(t, "the domain of user email allowedUser1@example1.org conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", resp.Header().Get("X-Gitea-Warning")) + + req = NewRequest(t, "DELETE", "/api/v1/admin/users/allowedUser1").AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) +} + +func TestAPIEditUser_NotAllowedEmailDomain(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + setting.Service.EmailDomainAllowList = []glob.Glob{glob.MustCompile("example.org")} + defer func() { + setting.Service.EmailDomainAllowList = []glob.Glob{} + }() + + adminUsername := "user1" + token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin) + urlStr := fmt.Sprintf("/api/v1/admin/users/%s", "user2") + + newEmail := "user2@example1.com" + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{ + Email: &newEmail, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "the domain of user email user2@example1.com conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", resp.Header().Get("X-Gitea-Warning")) + + originalEmail := "user2@example.com" + req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{ + Email: &originalEmail, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) +} diff --git a/tests/integration/api_block_test.go b/tests/integration/api_block_test.go new file mode 100644 index 0000000..a69ee9b --- /dev/null +++ b/tests/integration/api_block_test.go @@ -0,0 +1,228 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIUserBlock(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := "user4" + session := loginUser(t, user) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + t.Run("BlockUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", "/api/v1/user/block/user2").AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 4, BlockID: 2}) + }) + + t.Run("ListBlocked", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user/list_blocked").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + // One user just got blocked and the other one is defined in the fixtures. + assert.Equal(t, "2", resp.Header().Get("X-Total-Count")) + + var blockedUsers []api.BlockedUser + DecodeJSON(t, resp, &blockedUsers) + assert.Len(t, blockedUsers, 2) + assert.EqualValues(t, 1, blockedUsers[0].BlockID) + assert.EqualValues(t, 2, blockedUsers[1].BlockID) + }) + + t.Run("UnblockUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", "/api/v1/user/unblock/user2").AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 4, BlockID: 2}) + }) + + t.Run("Organization as target", func(t *testing.T) { + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26, Type: user_model.UserTypeOrganization}) + + t.Run("Block", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/block/%s", org.Name)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 4, BlockID: org.ID}) + }) + + t.Run("Unblock", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/unblock/%s", org.Name)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + }) +} + +func TestAPIOrgBlock(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := "user5" + org := "org6" + session := loginUser(t, user) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization) + + t.Run("BlockUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/user2", org)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 6, BlockID: 2}) + }) + + t.Run("ListBlocked", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/list_blocked", org)).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "1", resp.Header().Get("X-Total-Count")) + + var blockedUsers []api.BlockedUser + DecodeJSON(t, resp, &blockedUsers) + assert.Len(t, blockedUsers, 1) + assert.EqualValues(t, 2, blockedUsers[0].BlockID) + }) + + t.Run("UnblockUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/unblock/user2", org)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 6, BlockID: 2}) + }) + + t.Run("Organization as target", func(t *testing.T) { + targetOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26, Type: user_model.UserTypeOrganization}) + + t.Run("Block", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/%s", org, targetOrg.Name)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 4, BlockID: targetOrg.ID}) + }) + + t.Run("Unblock", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/unblock/%s", org, targetOrg.Name)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + }) + + t.Run("Read scope token", func(t *testing.T) { + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization) + + t.Run("Write action", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/user2", org)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 6, BlockID: 2}) + }) + + t.Run("Read action", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/list_blocked", org)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + }) + }) + + t.Run("Not as owner", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + org := "org3" + user := "user4" // Part of org team with write perms. + + session := loginUser(t, user) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization) + + t.Run("Block user", func(t *testing.T) { + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/user2", org)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 3, BlockID: 2}) + }) + + t.Run("Unblock user", func(t *testing.T) { + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/unblock/user2", org)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("List blocked users", func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/list_blocked", org)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + }) + }) +} + +// TestAPIBlock_AddCollaborator ensures that the doer and blocked user cannot +// add each others as collaborators via the API. +func TestAPIBlock_AddCollaborator(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user1 := "user10" + user2 := "user2" + perm := "write" + collabOption := &api.AddCollaboratorOption{Permission: &perm} + + // User1 blocks User2. + session := loginUser(t, user1) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/block/%s", user2)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 10, BlockID: 2}) + + t.Run("BlockedUser Add Doer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: 2}) + session := loginUser(t, user2) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s", user2, repo.Name, user1), collabOption).AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("Doer Add BlockedUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 7, OwnerID: 10}) + session := loginUser(t, user1) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s", user1, repo.Name, user2), collabOption).AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusForbidden) + }) +} diff --git a/tests/integration/api_branch_test.go b/tests/integration/api_branch_test.go new file mode 100644 index 0000000..63159f3 --- /dev/null +++ b/tests/integration/api_branch_test.go @@ -0,0 +1,268 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/modules/git" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testAPIGetBranch(t *testing.T, branchName string, exists bool) { + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository) + req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/branches/%s", branchName). + AddTokenAuth(token) + resp := MakeRequest(t, req, NoExpectedStatus) + if !exists { + assert.EqualValues(t, http.StatusNotFound, resp.Code) + return + } + assert.EqualValues(t, http.StatusOK, resp.Code) + var branch api.Branch + DecodeJSON(t, resp, &branch) + assert.EqualValues(t, branchName, branch.Name) + assert.True(t, branch.UserCanPush) + assert.True(t, branch.UserCanMerge) +} + +func testAPIGetBranchProtection(t *testing.T, branchName string, expectedHTTPStatus int) *api.BranchProtection { + token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository) + req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/branch_protections/%s", branchName). + AddTokenAuth(token) + resp := MakeRequest(t, req, expectedHTTPStatus) + + if resp.Code == http.StatusOK { + var branchProtection api.BranchProtection + DecodeJSON(t, resp, &branchProtection) + assert.EqualValues(t, branchName, branchProtection.RuleName) + return &branchProtection + } + return nil +} + +func testAPICreateBranchProtection(t *testing.T, branchName string, expectedHTTPStatus int) { + token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteRepository) + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/branch_protections", &api.BranchProtection{ + RuleName: branchName, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, expectedHTTPStatus) + + if resp.Code == http.StatusCreated { + var branchProtection api.BranchProtection + DecodeJSON(t, resp, &branchProtection) + assert.EqualValues(t, branchName, branchProtection.RuleName) + } +} + +func testAPIEditBranchProtection(t *testing.T, branchName string, body *api.BranchProtection, expectedHTTPStatus int) { + token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteRepository) + req := NewRequestWithJSON(t, "PATCH", "/api/v1/repos/user2/repo1/branch_protections/"+branchName, body). + AddTokenAuth(token) + resp := MakeRequest(t, req, expectedHTTPStatus) + + if resp.Code == http.StatusOK { + var branchProtection api.BranchProtection + DecodeJSON(t, resp, &branchProtection) + assert.EqualValues(t, branchName, branchProtection.RuleName) + } +} + +func testAPIDeleteBranchProtection(t *testing.T, branchName string, expectedHTTPStatus int) { + token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteRepository) + req := NewRequestf(t, "DELETE", "/api/v1/repos/user2/repo1/branch_protections/%s", branchName). + AddTokenAuth(token) + MakeRequest(t, req, expectedHTTPStatus) +} + +func testAPIDeleteBranch(t *testing.T, branchName string, expectedHTTPStatus int) { + token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteRepository) + req := NewRequestf(t, "DELETE", "/api/v1/repos/user2/repo1/branches/%s", branchName). + AddTokenAuth(token) + MakeRequest(t, req, expectedHTTPStatus) +} + +func TestAPIGetBranch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + for _, test := range []struct { + BranchName string + Exists bool + }{ + {"master", true}, + {"master/doesnotexist", false}, + {"feature/1", true}, + {"feature/1/doesnotexist", false}, + } { + testAPIGetBranch(t, test.BranchName, test.Exists) + } +} + +func TestAPICreateBranch(t *testing.T) { + onGiteaRun(t, testAPICreateBranches) +} + +func testAPICreateBranches(t *testing.T, giteaURL *url.URL) { + forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) { + ctx := NewAPITestContext(t, "user2", "my-noo-repo-"+objectFormat.Name(), auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + giteaURL.Path = ctx.GitPath() + + t.Run("CreateRepo", doAPICreateRepository(ctx, false, objectFormat)) + testCases := []struct { + OldBranch string + NewBranch string + ExpectedHTTPStatus int + }{ + // Creating branch from default branch + { + OldBranch: "", + NewBranch: "new_branch_from_default_branch", + ExpectedHTTPStatus: http.StatusCreated, + }, + // Creating branch from master + { + OldBranch: "master", + NewBranch: "new_branch_from_master_1", + ExpectedHTTPStatus: http.StatusCreated, + }, + // Trying to create from master but already exists + { + OldBranch: "master", + NewBranch: "new_branch_from_master_1", + ExpectedHTTPStatus: http.StatusConflict, + }, + // Trying to create from other branch (not default branch) + // ps: it can't test the case-sensitive behavior here: the "BRANCH_2" can't be created by git on a case-insensitive filesystem, it makes the test fail quickly before the database code. + // Suppose some users are running Gitea on a case-insensitive filesystem, it seems that it's unable to support case-sensitive branch names. + { + OldBranch: "new_branch_from_master_1", + NewBranch: "branch_2", + ExpectedHTTPStatus: http.StatusCreated, + }, + // Trying to create from a branch which does not exist + { + OldBranch: "does_not_exist", + NewBranch: "new_branch_from_non_existent", + ExpectedHTTPStatus: http.StatusNotFound, + }, + // Trying to create a branch with UTF8 + { + OldBranch: "master", + NewBranch: "test-👀", + ExpectedHTTPStatus: http.StatusCreated, + }, + } + for _, test := range testCases { + session := ctx.Session + t.Run(test.NewBranch, func(t *testing.T) { + testAPICreateBranch(t, session, ctx.Username, ctx.Reponame, test.OldBranch, test.NewBranch, test.ExpectedHTTPStatus) + }) + } + }) +} + +func testAPICreateBranch(t testing.TB, session *TestSession, user, repo, oldBranch, newBranch string, status int) bool { + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/"+user+"/"+repo+"/branches", &api.CreateBranchRepoOption{ + BranchName: newBranch, + OldBranchName: oldBranch, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, status) + + var branch api.Branch + DecodeJSON(t, resp, &branch) + + if resp.Result().StatusCode == http.StatusCreated { + assert.EqualValues(t, newBranch, branch.Name) + } + + return resp.Result().StatusCode == status +} + +func TestAPIBranchProtection(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // Branch protection on branch that not exist + testAPICreateBranchProtection(t, "master/doesnotexist", http.StatusCreated) + // Get branch protection on branch that exist but not branch protection + testAPIGetBranchProtection(t, "master", http.StatusNotFound) + + testAPICreateBranchProtection(t, "master", http.StatusCreated) + // Can only create once + testAPICreateBranchProtection(t, "master", http.StatusForbidden) + + // Can't delete a protected branch + testAPIDeleteBranch(t, "master", http.StatusForbidden) + + testAPIGetBranchProtection(t, "master", http.StatusOK) + testAPIEditBranchProtection(t, "master", &api.BranchProtection{ + EnablePush: true, + }, http.StatusOK) + + // enable status checks, require the "test1" check to pass + testAPIEditBranchProtection(t, "master", &api.BranchProtection{ + EnableStatusCheck: true, + StatusCheckContexts: []string{"test1"}, + }, http.StatusOK) + bp := testAPIGetBranchProtection(t, "master", http.StatusOK) + assert.True(t, bp.EnableStatusCheck) + assert.Equal(t, []string{"test1"}, bp.StatusCheckContexts) + + // disable status checks, clear the list of required checks + testAPIEditBranchProtection(t, "master", &api.BranchProtection{ + EnableStatusCheck: false, + StatusCheckContexts: []string{}, + }, http.StatusOK) + bp = testAPIGetBranchProtection(t, "master", http.StatusOK) + assert.False(t, bp.EnableStatusCheck) + assert.Equal(t, []string{}, bp.StatusCheckContexts) + + testAPIDeleteBranchProtection(t, "master", http.StatusNoContent) + + // Test branch deletion + testAPIDeleteBranch(t, "master", http.StatusForbidden) + testAPIDeleteBranch(t, "branch2", http.StatusNoContent) +} + +func TestAPICreateBranchWithSyncBranches(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + branches, err := db.Find[git_model.Branch](db.DefaultContext, git_model.FindBranchOptions{ + RepoID: 1, + }) + require.NoError(t, err) + assert.Len(t, branches, 4) + + // make a broke repository with no branch on database + _, err = db.DeleteByBean(db.DefaultContext, git_model.Branch{RepoID: 1}) + require.NoError(t, err) + + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + ctx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + giteaURL.Path = ctx.GitPath() + + testAPICreateBranch(t, ctx.Session, "user2", "repo1", "", "new_branch", http.StatusCreated) + }) + + branches, err = db.Find[git_model.Branch](db.DefaultContext, git_model.FindBranchOptions{ + RepoID: 1, + }) + require.NoError(t, err) + assert.Len(t, branches, 5) + + branches, err = db.Find[git_model.Branch](db.DefaultContext, git_model.FindBranchOptions{ + RepoID: 1, + Keyword: "new_branch", + }) + require.NoError(t, err) + assert.Len(t, branches, 1) +} diff --git a/tests/integration/api_comment_attachment_test.go b/tests/integration/api_comment_attachment_test.go new file mode 100644 index 0000000..db1b98a --- /dev/null +++ b/tests/integration/api_comment_attachment_test.go @@ -0,0 +1,278 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIGetCommentAttachment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) + require.NoError(t, comment.LoadIssue(db.DefaultContext)) + require.NoError(t, comment.LoadAttachments(db.DefaultContext)) + attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: comment.Attachments[0].ID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: comment.Issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + t.Run("UnrelatedCommentID", func(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d/assets/%d", repoOwner.Name, repo.Name, comment.ID, attachment.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d", repoOwner.Name, repo.Name, comment.ID). + AddTokenAuth(token) + resp := session.MakeRequest(t, req, http.StatusOK) + var apiComment api.Comment + DecodeJSON(t, resp, &apiComment) + assert.NotEmpty(t, apiComment.Attachments) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d/assets/%d", repoOwner.Name, repo.Name, comment.ID, attachment.ID). + AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusOK) + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d/assets/%d", repoOwner.Name, repo.Name, comment.ID, attachment.ID). + AddTokenAuth(token) + resp = session.MakeRequest(t, req, http.StatusOK) + + var apiAttachment api.Attachment + DecodeJSON(t, resp, &apiAttachment) + + expect := convert.ToAPIAttachment(repo, attachment) + assert.Equal(t, expect.ID, apiAttachment.ID) + assert.Equal(t, expect.Name, apiAttachment.Name) + assert.Equal(t, expect.UUID, apiAttachment.UUID) + assert.Equal(t, expect.Created.Unix(), apiAttachment.Created.Unix()) + assert.Equal(t, expect.DownloadURL, apiAttachment.DownloadURL) +} + +func TestAPIListCommentAttachments(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d/assets", repoOwner.Name, repo.Name, comment.ID). + AddTokenAuth(token) + resp := session.MakeRequest(t, req, http.StatusOK) + + var apiAttachments []*api.Attachment + DecodeJSON(t, resp, &apiAttachments) + expectedCount := unittest.GetCount(t, &repo_model.Attachment{CommentID: comment.ID}) + assert.Len(t, apiAttachments, expectedCount) + + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachments[0].ID, CommentID: comment.ID}) +} + +func TestAPICreateCommentAttachment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + filename := "image.png" + buff := generateImg() + body := &bytes.Buffer{} + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + require.NoError(t, err) + _, err = io.Copy(part, &buff) + require.NoError(t, err) + err = writer.Close() + require.NoError(t, err) + + req := NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets", repoOwner.Name, repo.Name, comment.ID), body). + AddTokenAuth(token). + SetHeader("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, CommentID: comment.ID}) +} + +func TestAPICreateCommentAttachmentAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets", + repoOwner.Name, repo.Name, comment.ID) + + filename := "image.png" + buff := generateImg() + body := &bytes.Buffer{} + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + require.NoError(t, err) + _, err = io.Copy(part, &buff) + require.NoError(t, err) + err = writer.Close() + require.NoError(t, err) + + req := NewRequestWithBody(t, "POST", urlStr, body).AddTokenAuth(token) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID}) + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(apiAttachment.Created) + assert.LessOrEqual(t, updatedSince, time.Minute) + + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID}) + updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + urlStr += fmt.Sprintf("?updated_at=%s", updatedAt.UTC().Format(time.RFC3339)) + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + require.NoError(t, err) + _, err = io.Copy(part, &buff) + require.NoError(t, err) + err = writer.Close() + require.NoError(t, err) + + req := NewRequestWithBody(t, "POST", urlStr, body).AddTokenAuth(token) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID}) + assert.Equal(t, updatedAt.In(utcTZ), apiAttachment.Created.In(utcTZ)) + + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID}) + assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) +} + +func TestAPIEditCommentAttachment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + const newAttachmentName = "newAttachmentName" + + attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 6}) + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: attachment.CommentID}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets/%d", + repoOwner.Name, repo.Name, comment.ID, attachment.ID) + req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{ + "name": newAttachmentName, + }).AddTokenAuth(token) + resp := session.MakeRequest(t, req, http.StatusCreated) + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, CommentID: comment.ID, Name: apiAttachment.Name}) +} + +func TestAPIDeleteCommentAttachment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 6}) + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: attachment.CommentID}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets/%d", repoOwner.Name, repo.Name, comment.ID, attachment.ID)). + AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertNotExistsBean(t, &repo_model.Attachment{ID: attachment.ID, CommentID: comment.ID}) +} + +func TestAPICreateCommentAttachmentWithUnallowedFile(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + filename := "file.bad" + body := &bytes.Buffer{} + + // Setup multi-part. + writer := multipart.NewWriter(body) + _, err := writer.CreateFormFile("attachment", filename) + require.NoError(t, err) + err = writer.Close() + require.NoError(t, err) + + req := NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets", repoOwner.Name, repo.Name, comment.ID), body). + AddTokenAuth(token). + SetHeader("Content-Type", writer.FormDataContentType()) + + session.MakeRequest(t, req, http.StatusUnprocessableEntity) +} diff --git a/tests/integration/api_comment_test.go b/tests/integration/api_comment_test.go new file mode 100644 index 0000000..a53b56d --- /dev/null +++ b/tests/integration/api_comment_test.go @@ -0,0 +1,467 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/references" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIListRepoComments(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{}, + unittest.Cond("type = ?", issues_model.CommentTypeComment)) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments", repoOwner.Name, repo.Name)) + req := NewRequest(t, "GET", link.String()) + resp := MakeRequest(t, req, http.StatusOK) + + var apiComments []*api.Comment + DecodeJSON(t, resp, &apiComments) + assert.Len(t, apiComments, 3) + for _, apiComment := range apiComments { + c := &issues_model.Comment{ID: apiComment.ID} + unittest.AssertExistsAndLoadBean(t, c, + unittest.Cond("type = ?", issues_model.CommentTypeComment)) + unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: c.IssueID, RepoID: repo.ID}) + } + + // test before and since filters + query := url.Values{} + before := "2000-01-01T00:00:11+00:00" // unix: 946684811 + since := "2000-01-01T00:00:12+00:00" // unix: 946684812 + query.Add("before", before) + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiComments) + assert.Len(t, apiComments, 1) + assert.EqualValues(t, 2, apiComments[0].ID) + + query.Del("before") + query.Add("since", since) + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiComments) + assert.Len(t, apiComments, 2) + assert.EqualValues(t, 3, apiComments[0].ID) +} + +func TestAPIListIssueComments(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{}, + unittest.Cond("type = ?", issues_model.CommentTypeComment)) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeReadIssue) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/%d/comments", repoOwner.Name, repo.Name, issue.Index). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var comments []*api.Comment + DecodeJSON(t, resp, &comments) + expectedCount := unittest.GetCount(t, &issues_model.Comment{IssueID: issue.ID}, + unittest.Cond("type = ?", issues_model.CommentTypeComment)) + assert.Len(t, comments, expectedCount) +} + +func TestAPICreateComment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + const commentBody = "Comment body" + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", + repoOwner.Name, repo.Name, issue.Index) + req := NewRequestWithValues(t, "POST", urlStr, map[string]string{ + "body": commentBody, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + assert.EqualValues(t, commentBody, updatedComment.Body) + unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody}) +} + +func TestAPICreateCommentAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", + repoOwner.Name, repo.Name, issue.Index) + const commentBody = "Comment body" + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", urlStr, map[string]string{ + "body": commentBody, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(updatedComment.Updated) + assert.LessOrEqual(t, updatedSince, time.Minute) + + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody}) + updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueCommentOption{ + Body: commentBody, + Updated: &updatedAt, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + assert.Equal(t, updatedAt.In(utcTZ), updatedComment.Updated.In(utcTZ)) + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody}) + assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) +} + +func TestAPICommentXRefAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create a comment mentioning issue #2 and check that a xref comment was added + // in issue #2 + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", + repoOwner.Name, repo.Name, issue.Index) + + commentBody := "mention #2" + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueCommentOption{ + Body: commentBody, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var createdComment api.Comment + DecodeJSON(t, resp, &createdComment) + + ref := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: 2, RefIssueID: 1, RefCommentID: createdComment.ID}) + assert.Equal(t, issues_model.CommentTypeCommentRef, ref.Type) + assert.Equal(t, references.XRefActionNone, ref.RefAction) + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(ref.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + + // Remove the mention to issue #2 and check that the xref was neutered + urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d", + repoOwner.Name, repo.Name, createdComment.ID) + + newCommentBody := "no mention" + req = NewRequestWithJSON(t, "PATCH", urlStr, &api.EditIssueCommentOption{ + Body: newCommentBody, + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + ref = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: 2, RefIssueID: 1, RefCommentID: updatedComment.ID}) + assert.Equal(t, issues_model.CommentTypeCommentRef, ref.Type) + assert.Equal(t, references.XRefActionNeutered, ref.RefAction) + // the execution of the API call supposedly lasted less than one minute + updatedSince = time.Since(ref.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + + // Create a comment mentioning issue #2 and check that a xref comment was added + // in issue #2 + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", + repoOwner.Name, repo.Name, issue.Index) + + commentBody := "re-mention #2" + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueCommentOption{ + Body: commentBody, + Updated: &updatedAt, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var createdComment api.Comment + DecodeJSON(t, resp, &createdComment) + + ref := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: 2, RefIssueID: 1, RefCommentID: createdComment.ID}) + assert.Equal(t, issues_model.CommentTypeCommentRef, ref.Type) + assert.Equal(t, references.XRefActionNone, ref.RefAction) + assert.Equal(t, updatedAt.In(utcTZ), ref.UpdatedUnix.AsTimeInLocation(utcTZ)) + + // Remove the mention to issue #2 and check that the xref was neutered + urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d", + repoOwner.Name, repo.Name, createdComment.ID) + + newCommentBody := "no mention" + updatedAt = time.Now().Add(-time.Hour).Truncate(time.Second) + req = NewRequestWithJSON(t, "PATCH", urlStr, &api.EditIssueCommentOption{ + Body: newCommentBody, + Updated: &updatedAt, + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + ref = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: 2, RefIssueID: 1, RefCommentID: updatedComment.ID}) + assert.Equal(t, issues_model.CommentTypeCommentRef, ref.Type) + assert.Equal(t, references.XRefActionNeutered, ref.RefAction) + assert.Equal(t, updatedAt.In(utcTZ), ref.UpdatedUnix.AsTimeInLocation(utcTZ)) + }) +} + +func TestAPIGetComment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) + require.NoError(t, comment.LoadIssue(db.DefaultContext)) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: comment.Issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeReadIssue) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d", repoOwner.Name, repo.Name, comment.ID) + MakeRequest(t, req, http.StatusOK) + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d", repoOwner.Name, repo.Name, comment.ID). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var apiComment api.Comment + DecodeJSON(t, resp, &apiComment) + + require.NoError(t, comment.LoadPoster(db.DefaultContext)) + expect := convert.ToAPIComment(db.DefaultContext, repo, comment) + + assert.Equal(t, expect.ID, apiComment.ID) + assert.Equal(t, expect.Poster.FullName, apiComment.Poster.FullName) + assert.Equal(t, expect.Body, apiComment.Body) + assert.Equal(t, expect.Created.Unix(), apiComment.Created.Unix()) +} + +func TestAPIGetSystemUserComment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + for _, systemUser := range []*user_model.User{ + user_model.NewGhostUser(), + user_model.NewActionsUser(), + } { + body := fmt.Sprintf("Hello %s", systemUser.Name) + comment, err := issues_model.CreateComment(db.DefaultContext, &issues_model.CreateCommentOptions{ + Type: issues_model.CommentTypeComment, + Doer: systemUser, + Repo: repo, + Issue: issue, + Content: body, + }) + require.NoError(t, err) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d", repoOwner.Name, repo.Name, comment.ID) + resp := MakeRequest(t, req, http.StatusOK) + + var apiComment api.Comment + DecodeJSON(t, resp, &apiComment) + + if assert.NotNil(t, apiComment.Poster) { + if assert.Equal(t, systemUser.ID, apiComment.Poster.ID) { + require.NoError(t, comment.LoadPoster(db.DefaultContext)) + assert.Equal(t, systemUser.Name, apiComment.Poster.UserName) + } + } + assert.Equal(t, body, apiComment.Body) + } +} + +func TestAPIEditComment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + const newCommentBody = "This is the new comment body" + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 8}, + unittest.Cond("type = ?", issues_model.CommentTypeComment)) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + t.Run("UnrelatedCommentID", func(t *testing.T) { + // Using the ID of a comment that does not belong to the repository must fail + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d", + repoOwner.Name, repo.Name, comment.ID) + req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{ + "body": newCommentBody, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d", + repoOwner.Name, repo.Name, comment.ID) + req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{ + "body": newCommentBody, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + assert.EqualValues(t, comment.ID, updatedComment.ID) + assert.EqualValues(t, newCommentBody, updatedComment.Body) + unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody}) +} + +func TestAPIEditCommentWithDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{}, + unittest.Cond("type = ?", issues_model.CommentTypeComment)) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d", + repoOwner.Name, repo.Name, comment.ID) + const newCommentBody = "This is the new comment body" + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{ + "body": newCommentBody, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(updatedComment.Updated) + assert.LessOrEqual(t, updatedSince, time.Minute) + + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody}) + updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + + req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditIssueCommentOption{ + Body: newCommentBody, + Updated: &updatedAt, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + assert.Equal(t, updatedAt.In(utcTZ), updatedComment.Updated.In(utcTZ)) + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody}) + assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) +} + +func TestAPIDeleteComment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 8}, + unittest.Cond("type = ?", issues_model.CommentTypeComment)) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + t.Run("UnrelatedCommentID", func(t *testing.T) { + // Using the ID of a comment that does not belong to the repository must fail + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/issues/comments/%d", repoOwner.Name, repo.Name, comment.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/issues/comments/%d", repoOwner.Name, repo.Name, comment.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertNotExistsBean(t, &issues_model.Comment{ID: comment.ID}) +} + +func TestAPIListIssueTimeline(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // load comment + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // make request + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/%d/timeline", repoOwner.Name, repo.Name, issue.Index) + resp := MakeRequest(t, req, http.StatusOK) + + // check if lens of list returned by API and + // lists extracted directly from DB are the same + var comments []*api.TimelineComment + DecodeJSON(t, resp, &comments) + expectedCount := unittest.GetCount(t, &issues_model.Comment{IssueID: issue.ID}) + assert.Len(t, comments, expectedCount) +} diff --git a/tests/integration/api_feed_plain_text_titles_test.go b/tests/integration/api_feed_plain_text_titles_test.go new file mode 100644 index 0000000..b124778 --- /dev/null +++ b/tests/integration/api_feed_plain_text_titles_test.go @@ -0,0 +1,43 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestFeedPlainTextTitles(t *testing.T) { + // This test verifies that items' titles in feeds are generated as plain text. + // See https://codeberg.org/forgejo/forgejo/pulls/1595 + defer test.MockVariableValue(&setting.UI.DefaultShowFullName, true)() + + t.Run("Feed plain text titles", func(t *testing.T) { + t.Run("Atom", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/user2/repo1.atom") + resp := MakeRequest(t, req, http.StatusOK) + + data := resp.Body.String() + assert.Contains(t, data, "<title>the_1-user.with.all.allowedChars closed issue user2/repo1#4</title>") + }) + + t.Run("RSS", func(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/user2/repo1.rss") + resp := MakeRequest(t, req, http.StatusOK) + + data := resp.Body.String() + assert.Contains(t, data, "<title>the_1-user.with.all.allowedChars closed issue user2/repo1#4</title>") + }) + }) +} diff --git a/tests/integration/api_feed_user_test.go b/tests/integration/api_feed_user_test.go new file mode 100644 index 0000000..e0e5fae --- /dev/null +++ b/tests/integration/api_feed_user_test.go @@ -0,0 +1,132 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFeed(t *testing.T) { + defer tests.AddFixtures("tests/integration/fixtures/TestFeed/")() + defer tests.PrepareTestEnv(t)() + + t.Run("User", func(t *testing.T) { + t.Run("Atom", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2.atom") + resp := MakeRequest(t, req, http.StatusOK) + + data := resp.Body.String() + assert.Contains(t, data, `<feed xmlns="http://www.w3.org/2005/Atom"`) + }) + + t.Run("RSS", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2.rss") + resp := MakeRequest(t, req, http.StatusOK) + + data := resp.Body.String() + assert.Contains(t, data, `<rss version="2.0"`) + }) + }) + + t.Run("Repo", func(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + t.Run("Atom", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1.atom") + resp := MakeRequest(t, req, http.StatusOK) + + data := resp.Body.String() + assert.Contains(t, data, `<feed xmlns="http://www.w3.org/2005/Atom"`) + assert.Contains(t, data, "This is a very long text, so lets scream together: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + }) + t.Run("RSS", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1.rss") + resp := MakeRequest(t, req, http.StatusOK) + + data := resp.Body.String() + assert.Contains(t, data, `<rss version="2.0"`) + assert.Contains(t, data, "This is a very long text, so lets scream together: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + }) + }) + t.Run("Branch", func(t *testing.T) { + t.Run("Atom", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/atom/branch/master") + resp := MakeRequest(t, req, http.StatusOK) + + data := resp.Body.String() + assert.Contains(t, data, `<feed xmlns="http://www.w3.org/2005/Atom"`) + }) + t.Run("RSS", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/rss/branch/master") + resp := MakeRequest(t, req, http.StatusOK) + + data := resp.Body.String() + assert.Contains(t, data, `<rss version="2.0"`) + }) + }) + t.Run("Empty", func(t *testing.T) { + err := user_model.UpdateUserCols(db.DefaultContext, &user_model.User{ID: 30, ProhibitLogin: false}, "prohibit_login") + require.NoError(t, err) + + session := loginUser(t, "user30") + t.Run("Atom", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user30/empty/atom/branch/master") + session.MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", "/user30/empty.atom/src/branch/master") + session.MakeRequest(t, req, http.StatusNotFound) + }) + t.Run("RSS", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user30/empty/rss/branch/master") + session.MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", "/user30/empty.rss/src/branch/master") + session.MakeRequest(t, req, http.StatusNotFound) + }) + }) + }) + + t.Run("View permission", func(t *testing.T) { + t.Run("Anomynous", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequest(t, "GET", "/org3/repo3/rss/branch/master") + MakeRequest(t, req, http.StatusNotFound) + }) + t.Run("No code permission", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + session := loginUser(t, "user8") + req := NewRequest(t, "GET", "/org3/repo3/rss/branch/master") + session.MakeRequest(t, req, http.StatusNotFound) + }) + t.Run("With code permission", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + session := loginUser(t, "user9") + req := NewRequest(t, "GET", "/org3/repo3/rss/branch/master") + session.MakeRequest(t, req, http.StatusOK) + }) + }) +} diff --git a/tests/integration/api_forgejo_root_test.go b/tests/integration/api_forgejo_root_test.go new file mode 100644 index 0000000..d21c944 --- /dev/null +++ b/tests/integration/api_forgejo_root_test.go @@ -0,0 +1,21 @@ +// Copyright The Forgejo Authors. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIForgejoRoot(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/api/forgejo/v1") + resp := MakeRequest(t, req, http.StatusNoContent) + assert.Contains(t, resp.Header().Get("Link"), "/assets/forgejo/api.v1.yml") +} diff --git a/tests/integration/api_forgejo_version_test.go b/tests/integration/api_forgejo_version_test.go new file mode 100644 index 0000000..5c95fd3 --- /dev/null +++ b/tests/integration/api_forgejo_version_test.go @@ -0,0 +1,59 @@ +// Copyright The Forgejo Authors. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + v1 "code.gitea.io/gitea/routers/api/forgejo/v1" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIForgejoVersion(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("Version", func(t *testing.T) { + req := NewRequest(t, "GET", "/api/forgejo/v1/version") + resp := MakeRequest(t, req, http.StatusOK) + + var version v1.Version + DecodeJSON(t, resp, &version) + assert.Equal(t, "1.0.0", *version.Version) + }) + + t.Run("Versions with REQUIRE_SIGNIN_VIEW enabled", func(t *testing.T) { + defer test.MockVariableValue(&setting.Service.RequireSignInView, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + t.Run("Get forgejo version without auth", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // GET api without auth + req := NewRequest(t, "GET", "/api/forgejo/v1/version") + MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("Get forgejo version without auth", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + username := "user1" + session := loginUser(t, username) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // GET api with auth + req := NewRequest(t, "GET", "/api/forgejo/v1/version").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var version v1.Version + DecodeJSON(t, resp, &version) + assert.Equal(t, "1.0.0", *version.Version) + }) + }) +} diff --git a/tests/integration/api_fork_test.go b/tests/integration/api_fork_test.go new file mode 100644 index 0000000..15b60f8 --- /dev/null +++ b/tests/integration/api_fork_test.go @@ -0,0 +1,153 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIForkAsAdminIgnoringLimits(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Repository.AllowForkWithoutMaximumLimit, false)() + defer test.MockVariableValue(&setting.Repository.MaxCreationLimit, 0)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}) + userSession := loginUser(t, user.Name) + userToken := getTokenForLoggedInUser(t, userSession, auth_model.AccessTokenScopeWriteRepository) + adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) + adminSession := loginUser(t, adminUser.Name) + adminToken := getTokenForLoggedInUser(t, adminSession, + auth_model.AccessTokenScopeWriteRepository, + auth_model.AccessTokenScopeWriteOrganization) + + originForkURL := "/api/v1/repos/user12/repo10/forks" + orgName := "fork-org" + + // Create an organization + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{ + UserName: orgName, + }).AddTokenAuth(adminToken) + MakeRequest(t, req, http.StatusCreated) + + // Create a team + teamToCreate := &api.CreateTeamOption{ + Name: "testers", + IncludesAllRepositories: true, + Permission: "write", + Units: []string{"repo.code", "repo.issues"}, + } + + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", orgName), &teamToCreate).AddTokenAuth(adminToken) + resp := MakeRequest(t, req, http.StatusCreated) + var team api.Team + DecodeJSON(t, resp, &team) + + // Add user2 to the team + req = NewRequestf(t, "PUT", "/api/v1/teams/%d/members/user2", team.ID).AddTokenAuth(adminToken) + MakeRequest(t, req, http.StatusNoContent) + + t.Run("forking as regular user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", originForkURL, &api.CreateForkOption{ + Organization: &orgName, + }).AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusConflict) + }) + + t.Run("forking as an instance admin", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", originForkURL, &api.CreateForkOption{ + Organization: &orgName, + }).AddTokenAuth(adminToken) + MakeRequest(t, req, http.StatusAccepted) + }) +} + +func TestCreateForkNoLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{}) + MakeRequest(t, req, http.StatusUnauthorized) +} + +func TestAPIDisabledForkRepo(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + defer test.MockVariableValue(&setting.Repository.DisableForks, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + t.Run("fork listing", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks") + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("forking", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, "user5") + token := getTokenForLoggedInUser(t, session) + + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{}).AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusNotFound) + }) + }) +} + +func TestAPIForkListPrivateRepo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user5") + token := getTokenForLoggedInUser(t, session, + auth_model.AccessTokenScopeWriteRepository, + auth_model.AccessTokenScopeWriteOrganization) + org23 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23, Visibility: api.VisibleTypePrivate}) + + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{ + Organization: &org23.Name, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusAccepted) + + t.Run("Anomynous", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks") + resp := MakeRequest(t, req, http.StatusOK) + + var forks []*api.Repository + DecodeJSON(t, resp, &forks) + + assert.Empty(t, forks) + assert.EqualValues(t, "0", resp.Header().Get("X-Total-Count")) + }) + + t.Run("Logged in", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var forks []*api.Repository + DecodeJSON(t, resp, &forks) + + assert.Len(t, forks, 1) + assert.EqualValues(t, "1", resp.Header().Get("X-Total-Count")) + }) +} diff --git a/tests/integration/api_gitignore_templates_test.go b/tests/integration/api_gitignore_templates_test.go new file mode 100644 index 0000000..c58f5ee --- /dev/null +++ b/tests/integration/api_gitignore_templates_test.go @@ -0,0 +1,53 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + "code.gitea.io/gitea/modules/options" + repo_module "code.gitea.io/gitea/modules/repository" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIListGitignoresTemplates(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/api/v1/gitignore/templates") + resp := MakeRequest(t, req, http.StatusOK) + + // This tests if the API returns a list of strings + var gitignoreList []string + DecodeJSON(t, resp, &gitignoreList) +} + +func TestAPIGetGitignoreTemplateInfo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // If Gitea has for some reason no Gitignore templates, we need to skip this test + if len(repo_module.Gitignores) == 0 { + return + } + + // Use the first template for the test + templateName := repo_module.Gitignores[0] + + urlStr := fmt.Sprintf("/api/v1/gitignore/templates/%s", templateName) + req := NewRequest(t, "GET", urlStr) + resp := MakeRequest(t, req, http.StatusOK) + + var templateInfo api.GitignoreTemplateInfo + DecodeJSON(t, resp, &templateInfo) + + // We get the text of the template here + text, _ := options.Gitignore(templateName) + + assert.Equal(t, templateInfo.Name, templateName) + assert.Equal(t, templateInfo.Source, string(text)) +} diff --git a/tests/integration/api_gpg_keys_test.go b/tests/integration/api_gpg_keys_test.go new file mode 100644 index 0000000..ec0dafc --- /dev/null +++ b/tests/integration/api_gpg_keys_test.go @@ -0,0 +1,279 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/http/httptest" + "strconv" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +type makeRequestFunc func(testing.TB, *RequestWrapper, int) *httptest.ResponseRecorder + +func TestGPGKeys(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + tokenWithGPGKeyScope := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + tt := []struct { + name string + makeRequest makeRequestFunc + token string + results []int + }{ + { + name: "NoLogin", makeRequest: MakeRequest, token: "", + results: []int{http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized}, + }, + { + name: "LoggedAsUser2", makeRequest: session.MakeRequest, token: token, + results: []int{http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden}, + }, + { + name: "LoggedAsUser2WithScope", makeRequest: session.MakeRequest, token: tokenWithGPGKeyScope, + results: []int{http.StatusOK, http.StatusOK, http.StatusNotFound, http.StatusNoContent, http.StatusUnprocessableEntity, http.StatusNotFound, http.StatusCreated, http.StatusNotFound, http.StatusCreated}, + }, + } + + for _, tc := range tt { + // Basic test on result code + t.Run(tc.name, func(t *testing.T) { + t.Run("ViewOwnGPGKeys", func(t *testing.T) { + testViewOwnGPGKeys(t, tc.makeRequest, tc.token, tc.results[0]) + }) + t.Run("ViewGPGKeys", func(t *testing.T) { + testViewGPGKeys(t, tc.makeRequest, tc.token, tc.results[1]) + }) + t.Run("GetGPGKey", func(t *testing.T) { + testGetGPGKey(t, tc.makeRequest, tc.token, tc.results[2]) + }) + t.Run("DeleteGPGKey", func(t *testing.T) { + testDeleteGPGKey(t, tc.makeRequest, tc.token, tc.results[3]) + }) + + t.Run("CreateInvalidGPGKey", func(t *testing.T) { + testCreateInvalidGPGKey(t, tc.makeRequest, tc.token, tc.results[4]) + }) + t.Run("CreateNoneRegistredEmailGPGKey", func(t *testing.T) { + testCreateNoneRegistredEmailGPGKey(t, tc.makeRequest, tc.token, tc.results[5]) + }) + t.Run("CreateValidGPGKey", func(t *testing.T) { + testCreateValidGPGKey(t, tc.makeRequest, tc.token, tc.results[6]) + }) + t.Run("CreateValidSecondaryEmailGPGKeyNotActivated", func(t *testing.T) { + testCreateValidSecondaryEmailGPGKey(t, tc.makeRequest, tc.token, tc.results[7]) + }) + }) + } + + // Check state after basic add + t.Run("CheckState", func(t *testing.T) { + var keys []*api.GPGKey + + req := NewRequest(t, "GET", "/api/v1/user/gpg_keys"). // GET all keys + AddTokenAuth(tokenWithGPGKeyScope) + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &keys) + assert.Len(t, keys, 1) + + primaryKey1 := keys[0] // Primary key 1 + assert.EqualValues(t, "38EA3BCED732982C", primaryKey1.KeyID) + assert.Len(t, primaryKey1.Emails, 1) + assert.EqualValues(t, "user2@example.com", primaryKey1.Emails[0].Email) + assert.True(t, primaryKey1.Emails[0].Verified) + + subKey := primaryKey1.SubsKey[0] // Subkey of 38EA3BCED732982C + assert.EqualValues(t, "70D7C694D17D03AD", subKey.KeyID) + assert.Empty(t, subKey.Emails) + + var key api.GPGKey + req = NewRequest(t, "GET", "/api/v1/user/gpg_keys/"+strconv.FormatInt(primaryKey1.ID, 10)). // Primary key 1 + AddTokenAuth(tokenWithGPGKeyScope) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &key) + assert.EqualValues(t, "38EA3BCED732982C", key.KeyID) + assert.Len(t, key.Emails, 1) + assert.EqualValues(t, "user2@example.com", key.Emails[0].Email) + assert.True(t, key.Emails[0].Verified) + + req = NewRequest(t, "GET", "/api/v1/user/gpg_keys/"+strconv.FormatInt(subKey.ID, 10)). // Subkey of 38EA3BCED732982C + AddTokenAuth(tokenWithGPGKeyScope) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &key) + assert.EqualValues(t, "70D7C694D17D03AD", key.KeyID) + assert.Empty(t, key.Emails) + }) + + // Check state after basic add + t.Run("CheckCommits", func(t *testing.T) { + t.Run("NotSigned", func(t *testing.T) { + var branch api.Branch + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo16/branches/not-signed"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &branch) + assert.False(t, branch.Commit.Verification.Verified) + }) + + t.Run("SignedWithNotValidatedEmail", func(t *testing.T) { + var branch api.Branch + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo16/branches/good-sign-not-yet-validated"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &branch) + assert.False(t, branch.Commit.Verification.Verified) + }) + + t.Run("SignedWithValidEmail", func(t *testing.T) { + var branch api.Branch + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo16/branches/good-sign"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &branch) + assert.True(t, branch.Commit.Verification.Verified) + }) + }) +} + +func testViewOwnGPGKeys(t *testing.T, makeRequest makeRequestFunc, token string, expected int) { + req := NewRequest(t, "GET", "/api/v1/user/gpg_keys"). + AddTokenAuth(token) + makeRequest(t, req, expected) +} + +func testViewGPGKeys(t *testing.T, makeRequest makeRequestFunc, token string, expected int) { + req := NewRequest(t, "GET", "/api/v1/users/user2/gpg_keys"). + AddTokenAuth(token) + makeRequest(t, req, expected) +} + +func testGetGPGKey(t *testing.T, makeRequest makeRequestFunc, token string, expected int) { + req := NewRequest(t, "GET", "/api/v1/user/gpg_keys/1"). + AddTokenAuth(token) + makeRequest(t, req, expected) +} + +func testDeleteGPGKey(t *testing.T, makeRequest makeRequestFunc, token string, expected int) { + req := NewRequest(t, "DELETE", "/api/v1/user/gpg_keys/1"). + AddTokenAuth(token) + makeRequest(t, req, expected) +} + +func testCreateGPGKey(t *testing.T, makeRequest makeRequestFunc, token string, expected int, publicKey string) { + req := NewRequestWithJSON(t, "POST", "/api/v1/user/gpg_keys", api.CreateGPGKeyOption{ + ArmoredKey: publicKey, + }).AddTokenAuth(token) + makeRequest(t, req, expected) +} + +func testCreateInvalidGPGKey(t *testing.T, makeRequest makeRequestFunc, token string, expected int) { + testCreateGPGKey(t, makeRequest, token, expected, "invalid_key") +} + +func testCreateNoneRegistredEmailGPGKey(t *testing.T, makeRequest makeRequestFunc, token string, expected int) { + testCreateGPGKey(t, makeRequest, token, expected, `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBFmGUygBCACjCNbKvMGgp0fd5vyFW9olE1CLCSyyF9gQN2hSuzmZLuAZF2Kh +dCMCG2T1UwzUB/yWUFWJ2BtCwSjuaRv+cGohqEy6bhEBV90peGA33lHfjx7wP25O +7moAphDOTZtDj1AZfCh/PTcJut8Lc0eRDMhNyp/bYtO7SHNT1Hr6rrCV/xEtSAvR +3b148/tmIBiSadaLwc558KU3ucjnW5RVGins3AjBZ+TuT4XXVH/oeLSeXPSJ5rt1 +rHwaseslMqZ4AbvwFLx5qn1OC9rEQv/F548QsA8m0IntLjoPon+6wcubA9Gra21c +Fp6aRYl9x7fiqXDLg8i3s2nKdV7+e6as6Tp9ABEBAAG0FG5vdGtub3duQGV4YW1w +bGUuY29tiQEcBBABAgAGBQJZhlMoAAoJEC8+pvYULDtte/wH/2JNrhmHwDY+hMj0 +batIK4HICnkKxjIgbha80P2Ao08NkzSge58fsxiKDFYAQjHui+ZAw4dq79Ax9AOO +Iv2GS9+DUfWhrb6RF+vNuJldFzcI0rTW/z2q+XGKrUCwN3khJY5XngHfQQrdBtMK +qsoUXz/5B8g422RTbo/SdPsyYAV6HeLLeV3rdgjI1fpaW0seZKHeTXQb/HvNeuPg +qz+XV1g6Gdqa1RjDOaX7A8elVKxrYq3LBtc93FW+grBde8n7JL0zPM3DY+vJ0IJZ +INx/MmBfmtCq05FqNclvU+sj2R3N1JJOtBOjZrJHQbJhzoILou8AkxeX1A+q9OAz +1geiY5E= +=TkP3 +-----END PGP PUBLIC KEY BLOCK-----`) +} + +func testCreateValidGPGKey(t *testing.T, makeRequest makeRequestFunc, token string, expected int) { + // User2 <user2@example.com> //primary & activated + testCreateGPGKey(t, makeRequest, token, expected, `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBFmGVsMBCACuxgZ7W7rI9xN08Y4M7B8yx/6/I4Slm94+wXf8YNRvAyqj30dW +VJhyBcnfNRDLKSQp5o/hhfDkCgdqBjLa1PnHlGS3PXJc0hP/FyYPD2BFvNMPpCYS +eu3T1qKSNXm6X0XOWD2LIrdiDC8HaI9FqZVMI/srMK2CF8XCL2m67W1FuoPlWzod +5ORy0IZB7spoF0xihmcgnEGElRmdo5w/vkGH8U7Zyn9Eb57UVFeafgeskf4wqB23 +BjbMdW2YaB+yzMRwYgOnD5lnBD4uqSmvjaV9C0kxn7x+oJkkiRV8/z1cNcO+BaeQ +Akh/yTTeTzYGSc/ZOqCX1O+NOPgSeixVlqenABEBAAG0GVVzZXIyIDx1c2VyMkBl +eGFtcGxlLmNvbT6JAVQEEwEIAD4WIQRXgbSh0TtGbgRd7XI46jvO1zKYLAUCWYZW +wwIbAwUJA8JnAAULCQgHAgYVCAkKCwIEFgIDAQIeAQIXgAAKCRA46jvO1zKYLF/e +B/91wm2KLMIQBZBA9WA2/+9rQWTo9EqgYrXN60rEzX3cYJWXZiE4DrKR1oWDGNLi +KXOCW62snvJldolBqq0ZqaKvPKzl0Y5TRqbYEc9AjUSqgRin1b+G2DevLGT4ibq+ +7ocQvz0XkASEUAgHahp0Ubiiib1521WwT/duL+AG8Gg0+DK09RfV3eX5/EOkQCKv +8cutqgsd2Smz40A8wXuJkRcipZBtrB/GkUaZ/eJdwEeSYZjEA9GWF61LJT2stvRN +HCk7C3z3pVEek1PluiFs/4VN8BG8yDzW4c0tLty4Fj3VwPqwIbB5AJbquVfhQCb4 +Eep2lm3Lc9b1OwO5N3coPJkouQENBFmGVsMBCADAGba2L6NCOE1i3WIP6CPzbdOo +N3gdTfTgccAx9fNeon9jor+3tgEjlo9/6cXiRoksOV6W4wFab/ZwWgwN6JO4CGvZ +Wi7EQwMMMp1E36YTojKQJrcA9UvMnTHulqQQ88F5E845DhzFQM3erv42QZZMBAX3 +kXCgy1GNFocl6tLUvJdEqs+VcJGGANMpmzE4WLa8KhSYnxipwuQ62JBy9R+cHyKT +OARk8znRqSu5bT3LtlrZ/HXu+6Oy4+2uCdNzZIh5J5tPS7CPA6ptl88iGVBte/CJ +7cjgJWSQqeYp2Y5QvsWAivkQ4Ww9plHbbwV0A2eaHsjjWzlUl3HoJ/snMOhBABEB +AAGJATwEGAEIACYWIQRXgbSh0TtGbgRd7XI46jvO1zKYLAUCWYZWwwIbDAUJA8Jn +AAAKCRA46jvO1zKYLBwLCACQOpeRVrwIKVaWcPMYjVHHJsGscaLKpgpARAUgbiG6 +Cbc2WI8Sm3fRwrY0VAfN+u9QwrtvxANcyB3vTgTzw7FimfhOimxiTSO8HQCfjDZF +Xly8rq+Fua7+ClWUpy21IekW41VvZYjH2sL6EVP+UcEOaGAyN53XfhaRVZPhNtZN +NKAE9N5EG3rbsZ33LzJj40rEKlzFSseAAPft8qA3IXjzFBx+PQXHMpNCagL79he6 +lqockTJ+oPmta4CF/J0U5LUr1tOZXheL3TP6m8d08gDrtn0YuGOPk87i9sJz+jR9 +uy6MA3VSB99SK9ducGmE1Jv8mcziREroz2TEGr0zPs6h +=J59D +-----END PGP PUBLIC KEY BLOCK-----`) +} + +func testCreateValidSecondaryEmailGPGKey(t *testing.T, makeRequest makeRequestFunc, token string, expected int) { + // User2 <user2-2@example.com> //secondary and not activated + testCreateGPGKey(t, makeRequest, token, expected, `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQGNBGC2K2cBDAC1+Xgk+8UfhASVgRngQi4rnQ8k0t+bWsBz4Czd26+cxVDRwlTT +8PALdrbrY/e9iXjcVcZ8Npo4UYe7/LfnL57dc7tgbenRGYYrWyVoNNv58BVw4xCY +RmgvdHWIIPGuz3aME0smHxbJ2KewYTqjTPuVKF/wrHTwCpVWdjYKC5KDo3yx0mro +xf9vOJOnkWNMiEw7TiZfkrbUqxyA53BVsSNKRX5C3b4FJcVT7eiAq7sDAaFxjEHy +ahZslmvg7XZxWzSVzxDNesR7f4xuop8HBjzaluJoVuwiyWculTvz1b6hyHVQr+ad +h8JGjj1tySI65OTFsTuptsfHXjtjl/NR4P6BXkf+FVwweaTQaEzpHkv0m9b9pY43 +CY/8XtS4uNPermiLG/Z0BB1eOCdoOQVHpjOa55IXQWhxXB6NZVyowiUbrR7jLDQy +5JP7D1HmErTR8JRm3VDqGbSaCgugRgFX+lb/fpgFp9k02OeK+JQudolZOt1mVk+T +C4xmEWxfiH15/JMAEQEAAbQbdXNlcjIgPHVzZXIyLTJAZXhhbXBsZS5jb20+iQHU +BBMBCAA+FiEEB/Y4DM3Ba2H9iXmlPO9G70C+/D4FAmC2K2cCGwMFCQPCZwAFCwkI +BwIGFQoJCAsCBBYCAwECHgECF4AACgkQPO9G70C+/D59/Av/XZIhCH4X2FpxCO3d +oCa+sbYkBL5xeUoPfAx5ThXzqL/tllO88TKTMEGZF3k5pocXWH0xmhqlvDTcdb0i +W3O0CN8FLmuotU51c0JC1mt9zwJP9PeJNyqxrMm01Yzj55z/Dz3QHSTlDjrWTWjn +YBqDf2HfdM177oydfSYmevZni1aDmBalWpFPRvqISCO7uFnvg1hJQ5mD/0qie663 +QJ8LAAANg32H9DyPnYi9wU62WX0DMUVTjKctT3cnYCbirjjJ7ZlCCm+cf61CRX1B +E1Ng/Ef3ZcUfXWitZSjfET/pKEMSNjsQawFpZ/LPCBl+UPHzaTPAASeGJvcbZ3py +wZQLQc1MCu2hmMBQ8zHQTdS2Pp0RISxCQLYvVQL6DrcJDNiSqn9p9RQt5c5r5Pjx +80BIPcjj3glOVP7PYE2azQAkt6reEjhimwCfjeDpiPnkBTY7Av2jCcUFhhemDY/j +TRXK1paLphhJ36zC22SeHGxNNakjjuUakqB85DEUeoWuVm6ouQGNBGC2K2cBDADx +G2rIAgMjdPtofhkEZXwv6zdNwmYOlIIM+59bam9Ep/vFq8F5f+xldevm5dvM8SeR +pNwDGSOUf5OKBWBdsJFhlYBl7+EcKd/Tent/XS6JoA9ffF33b+r04L543+ykiKON +WYeYi0F4WwYTIQgqZHJze1sPVkYGR5F0bL8PAcLuwd5dzZVi/q2HakrGdg29N8oY +b/XnoR7FflPrNYdzO6hawi5Inx7KS7aWa0ZkARb0F4HSct+/m6nAZVsoJINLudyQ +ut2NWeU8rWIm1hqyIxQFvuQJy46umq++10J/sWA98bkg41Rx+72+eP7DM5v8IgUp +clJsfljRXIBWbmRAVZvtNI7PX9fwMMhf4M7wHO7G2WV39o1exKps5xFFcn8PUQiX +jCSR81M145CgCdmLUR1y0pdkN/WIqjXBhkPIvO2dxEcodMNHb1aUUuUOnww6+xIP +8rGVw+a2DUiALc8Qr5RP21AYKRctfiwhSQh2KODveMtyLI3U9C/eLRPp+QM3XB8A +EQEAAYkBvAQYAQgAJhYhBAf2OAzNwWth/Yl5pTzvRu9Avvw+BQJgtitnAhsMBQkD +wmcAAAoJEDzvRu9Avvw+3FcMAJBwupyJ4zwQFxTJ5BkDlusG3U2FXEf3bDrXhvNd +qi8eS8Vo/vRiH/w/my5JFpz1o2tJToryF71D+uF5DTItalKquhsQ9reAEmXggqOh +9Jd9mWJIEEWcRORiLNDKENKvE8bouw4U4hRaSF0IaGzAe5mO+oOvwal8L97wFxrZ +4leM1GzkopiuNfbkkBBw2KJcMjYBHzzXSCALnVwhjbgkBEWPIg38APT3cr9KfnMM +q8+tvsGLj4piAl3Lww7+GhSsDOUXH8btR41BSAQDrbO5q6oi/h4nuxoNmQIDW/Ug +s+dd5hnY2FtHRjb4FCR9kAjdTE6stc8wzohWfbg1N+12TTA2ylByAumICVXixavH +RJ7l0OiWJk388qw9mqh3k8HcBxL7OfDlFC9oPmCS0iYiIwW/Yc80kBhoxcvl/Xa7 +mIMMn8taHIaQO7v9ln2EVQYTzbNCmwTw9ovTM0j/Pbkg2EftfP1TCoxQHvBnsCED +6qgtsUdi5eviONRkBgeZtN3oxA== +=MgDv +-----END PGP PUBLIC KEY BLOCK-----`) +} diff --git a/tests/integration/api_health_test.go b/tests/integration/api_health_test.go new file mode 100644 index 0000000..5657f4f --- /dev/null +++ b/tests/integration/api_health_test.go @@ -0,0 +1,25 @@ +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers/web/healthcheck" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestApiHeatlhCheck(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/api/healthz") + resp := MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Header().Values("Cache-Control"), "no-store") + + var status healthcheck.Response + DecodeJSON(t, resp, &status) + assert.Equal(t, healthcheck.Pass, status.Status) + assert.Equal(t, setting.AppName, status.Description) +} diff --git a/tests/integration/api_helper_for_declarative_test.go b/tests/integration/api_helper_for_declarative_test.go new file mode 100644 index 0000000..dae71ca --- /dev/null +++ b/tests/integration/api_helper_for_declarative_test.go @@ -0,0 +1,463 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + "time" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/queue" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/forms" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type APITestContext struct { + Reponame string + Session *TestSession + Token string + Username string + ExpectedCode int +} + +func NewAPITestContext(t *testing.T, username, reponame string, scope ...auth.AccessTokenScope) APITestContext { + session := loginUser(t, username) + token := getTokenForLoggedInUser(t, session, scope...) + return APITestContext{ + Session: session, + Token: token, + Username: username, + Reponame: reponame, + } +} + +func (ctx APITestContext) GitPath() string { + return fmt.Sprintf("%s/%s.git", ctx.Username, ctx.Reponame) +} + +func doAPICreateRepository(ctx APITestContext, empty bool, objectFormat git.ObjectFormat, callback ...func(*testing.T, api.Repository)) func(*testing.T) { + return func(t *testing.T) { + createRepoOption := &api.CreateRepoOption{ + AutoInit: !empty, + Description: "Temporary repo", + Name: ctx.Reponame, + Private: true, + Template: true, + Gitignores: "", + License: "WTFPL", + Readme: "Default", + ObjectFormatName: objectFormat.Name(), + } + req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", createRepoOption). + AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + resp := ctx.Session.MakeRequest(t, req, http.StatusCreated) + + var repository api.Repository + DecodeJSON(t, resp, &repository) + if len(callback) > 0 { + callback[0](t, repository) + } + } +} + +func doAPIEditRepository(ctx APITestContext, editRepoOption *api.EditRepoOption, callback ...func(*testing.T, api.Repository)) func(*testing.T) { + return func(t *testing.T) { + req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)), editRepoOption). + AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + resp := ctx.Session.MakeRequest(t, req, http.StatusOK) + + var repository api.Repository + DecodeJSON(t, resp, &repository) + if len(callback) > 0 { + callback[0](t, repository) + } + } +} + +func doAPIAddCollaborator(ctx APITestContext, username string, mode perm.AccessMode) func(*testing.T) { + return func(t *testing.T) { + permission := "read" + + if mode == perm.AccessModeAdmin { + permission = "admin" + } else if mode > perm.AccessModeRead { + permission = "write" + } + addCollaboratorOption := &api.AddCollaboratorOption{ + Permission: &permission, + } + req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s", ctx.Username, ctx.Reponame, username), addCollaboratorOption). + AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + ctx.Session.MakeRequest(t, req, http.StatusNoContent) + } +} + +func doAPIForkRepository(ctx APITestContext, username string, callback ...func(*testing.T, api.Repository)) func(*testing.T) { + return func(t *testing.T) { + createForkOption := &api.CreateForkOption{} + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", username, ctx.Reponame), createForkOption). + AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + resp := ctx.Session.MakeRequest(t, req, http.StatusAccepted) + var repository api.Repository + DecodeJSON(t, resp, &repository) + if len(callback) > 0 { + callback[0](t, repository) + } + } +} + +func doAPIGetRepository(ctx APITestContext, callback ...func(*testing.T, api.Repository)) func(*testing.T) { + return func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s", ctx.Username, ctx.Reponame)). + AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + resp := ctx.Session.MakeRequest(t, req, http.StatusOK) + + var repository api.Repository + DecodeJSON(t, resp, &repository) + if len(callback) > 0 { + callback[0](t, repository) + } + } +} + +func doAPIDeleteRepository(ctx APITestContext) func(*testing.T) { + return func(t *testing.T) { + req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s", ctx.Username, ctx.Reponame)). + AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + ctx.Session.MakeRequest(t, req, http.StatusNoContent) + } +} + +func doAPICreateUserKey(ctx APITestContext, keyname, keyFile string, callback ...func(*testing.T, api.PublicKey)) func(*testing.T) { + return func(t *testing.T) { + dataPubKey, err := os.ReadFile(keyFile + ".pub") + require.NoError(t, err) + req := NewRequestWithJSON(t, "POST", "/api/v1/user/keys", &api.CreateKeyOption{ + Title: keyname, + Key: string(dataPubKey), + }).AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + resp := ctx.Session.MakeRequest(t, req, http.StatusCreated) + var publicKey api.PublicKey + DecodeJSON(t, resp, &publicKey) + if len(callback) > 0 { + callback[0](t, publicKey) + } + } +} + +func doAPIDeleteUserKey(ctx APITestContext, keyID int64) func(*testing.T) { + return func(t *testing.T) { + req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/user/keys/%d", keyID)). + AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + ctx.Session.MakeRequest(t, req, http.StatusNoContent) + } +} + +func doAPICreateDeployKey(ctx APITestContext, keyname, keyFile string, readOnly bool) func(*testing.T) { + return func(t *testing.T) { + dataPubKey, err := os.ReadFile(keyFile + ".pub") + require.NoError(t, err) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/keys", ctx.Username, ctx.Reponame), api.CreateKeyOption{ + Title: keyname, + Key: string(dataPubKey), + ReadOnly: readOnly, + }).AddTokenAuth(ctx.Token) + + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + ctx.Session.MakeRequest(t, req, http.StatusCreated) + } +} + +func doAPICreatePullRequest(ctx APITestContext, owner, repo, baseBranch, headBranch string) func(*testing.T) (api.PullRequest, error) { + return func(t *testing.T) (api.PullRequest, error) { + req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner, repo), &api.CreatePullRequestOption{ + Head: headBranch, + Base: baseBranch, + Title: fmt.Sprintf("create a pr from %s to %s", headBranch, baseBranch), + }).AddTokenAuth(ctx.Token) + + expected := http.StatusCreated + if ctx.ExpectedCode != 0 { + expected = ctx.ExpectedCode + } + resp := ctx.Session.MakeRequest(t, req, expected) + + decoder := json.NewDecoder(resp.Body) + pr := api.PullRequest{} + err := decoder.Decode(&pr) + return pr, err + } +} + +func doAPIGetPullRequest(ctx APITestContext, owner, repo string, index int64) func(*testing.T) (api.PullRequest, error) { + return func(t *testing.T) (api.PullRequest, error) { + req := NewRequest(t, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index)). + AddTokenAuth(ctx.Token) + + expected := http.StatusOK + if ctx.ExpectedCode != 0 { + expected = ctx.ExpectedCode + } + resp := ctx.Session.MakeRequest(t, req, expected) + + decoder := json.NewDecoder(resp.Body) + pr := api.PullRequest{} + err := decoder.Decode(&pr) + return pr, err + } +} + +func doAPIMergePullRequest(ctx APITestContext, owner, repo string, index int64) func(*testing.T) { + return func(t *testing.T) { + t.Helper() + doAPIMergePullRequestForm(t, ctx, owner, repo, index, &forms.MergePullRequestForm{ + MergeMessageField: "doAPIMergePullRequest Merge", + Do: string(repo_model.MergeStyleMerge), + }) + } +} + +func doAPIMergePullRequestForm(t *testing.T, ctx APITestContext, owner, repo string, index int64, merge *forms.MergePullRequestForm) *httptest.ResponseRecorder { + t.Helper() + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index) + + var req *RequestWrapper + var resp *httptest.ResponseRecorder + + for i := 0; i < 6; i++ { + req = NewRequestWithJSON(t, http.MethodPost, urlStr, merge).AddTokenAuth(ctx.Token) + + resp = ctx.Session.MakeRequest(t, req, NoExpectedStatus) + + if resp.Code != http.StatusMethodNotAllowed { + break + } + err := api.APIError{} + DecodeJSON(t, resp, &err) + if err.Message != "Please try again later" { + break + } + queue.GetManager().FlushAll(context.Background(), 5*time.Second) + <-time.After(1 * time.Second) + } + + expected := ctx.ExpectedCode + if expected == 0 { + expected = http.StatusOK + } + + if !assert.EqualValues(t, expected, resp.Code, + "Request: %s %s", req.Method, req.URL.String()) { + logUnexpectedResponse(t, resp) + } + + return resp +} + +func doAPIManuallyMergePullRequest(ctx APITestContext, owner, repo, commitID string, index int64) func(*testing.T) { + return func(t *testing.T) { + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index) + req := NewRequestWithJSON(t, http.MethodPost, urlStr, &forms.MergePullRequestForm{ + Do: string(repo_model.MergeStyleManuallyMerged), + MergeCommitID: commitID, + }).AddTokenAuth(ctx.Token) + + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + ctx.Session.MakeRequest(t, req, http.StatusOK) + } +} + +func doAPIAutoMergePullRequest(ctx APITestContext, owner, repo string, index int64) func(*testing.T) { + return func(t *testing.T) { + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index) + req := NewRequestWithJSON(t, http.MethodPost, urlStr, &forms.MergePullRequestForm{ + MergeMessageField: "doAPIMergePullRequest Merge", + Do: string(repo_model.MergeStyleMerge), + MergeWhenChecksSucceed: true, + }).AddTokenAuth(ctx.Token) + + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + ctx.Session.MakeRequest(t, req, http.StatusOK) + } +} + +func doAPICancelAutoMergePullRequest(ctx APITestContext, owner, repo string, index int64) func(*testing.T) { + return func(t *testing.T) { + req := NewRequest(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index)). + AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + ctx.Session.MakeRequest(t, req, http.StatusNoContent) + } +} + +func doAPIGetBranch(ctx APITestContext, branch string, callback ...func(*testing.T, api.Branch)) func(*testing.T) { + return func(t *testing.T) { + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/branches/%s", ctx.Username, ctx.Reponame, branch). + AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + resp := ctx.Session.MakeRequest(t, req, http.StatusOK) + + var branch api.Branch + DecodeJSON(t, resp, &branch) + if len(callback) > 0 { + callback[0](t, branch) + } + } +} + +func doAPICreateFile(ctx APITestContext, treepath string, options *api.CreateFileOptions, callback ...func(*testing.T, api.FileResponse)) func(*testing.T) { + return func(t *testing.T) { + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", ctx.Username, ctx.Reponame, treepath), &options). + AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + resp := ctx.Session.MakeRequest(t, req, http.StatusCreated) + + var contents api.FileResponse + DecodeJSON(t, resp, &contents) + if len(callback) > 0 { + callback[0](t, contents) + } + } +} + +func doAPICreateOrganization(ctx APITestContext, options *api.CreateOrgOption, callback ...func(*testing.T, api.Organization)) func(t *testing.T) { + return func(t *testing.T) { + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &options). + AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + resp := ctx.Session.MakeRequest(t, req, http.StatusCreated) + + var contents api.Organization + DecodeJSON(t, resp, &contents) + if len(callback) > 0 { + callback[0](t, contents) + } + } +} + +func doAPICreateOrganizationRepository(ctx APITestContext, orgName string, options *api.CreateRepoOption, callback ...func(*testing.T, api.Repository)) func(t *testing.T) { + return func(t *testing.T) { + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName), &options). + AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + resp := ctx.Session.MakeRequest(t, req, http.StatusCreated) + + var contents api.Repository + DecodeJSON(t, resp, &contents) + if len(callback) > 0 { + callback[0](t, contents) + } + } +} + +func doAPICreateOrganizationTeam(ctx APITestContext, orgName string, options *api.CreateTeamOption, callback ...func(*testing.T, api.Team)) func(t *testing.T) { + return func(t *testing.T) { + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", orgName), &options). + AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + resp := ctx.Session.MakeRequest(t, req, http.StatusCreated) + + var contents api.Team + DecodeJSON(t, resp, &contents) + if len(callback) > 0 { + callback[0](t, contents) + } + } +} + +func doAPIAddUserToOrganizationTeam(ctx APITestContext, teamID int64, username string) func(t *testing.T) { + return func(t *testing.T) { + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/teams/%d/members/%s", teamID, username)). + AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + ctx.Session.MakeRequest(t, req, http.StatusNoContent) + } +} + +func doAPIAddRepoToOrganizationTeam(ctx APITestContext, teamID int64, orgName, repoName string) func(t *testing.T) { + return func(t *testing.T) { + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/teams/%d/repos/%s/%s", teamID, orgName, repoName)). + AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + ctx.Session.MakeRequest(t, req, http.StatusNoContent) + } +} diff --git a/tests/integration/api_httpsig_test.go b/tests/integration/api_httpsig_test.go new file mode 100644 index 0000000..30aed3c --- /dev/null +++ b/tests/integration/api_httpsig_test.go @@ -0,0 +1,142 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/tests" + + "github.com/go-fed/httpsig" + "golang.org/x/crypto/ssh" +) + +const ( + httpsigPrivateKey = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn +NhAAAAAwEAAQAAAQEAqjmQeb5Eb1xV7qbNf9ErQ0XRvKZWzUsLFhJzZz+Ab7q8WtPs91vQ +fBiypw4i8OTG6WzDcgZaV8Ndxn7iHnIstdA1k89MVG4stydymmwmk9+mrCMNsu5OmdIy9F +AZ61RDcKuf5VG2WKkmeK0VO+OMJIYfE1C6czNeJ6UAmcIOmhGxvjMI83XUO9n0ftwTwayp ++XU5prvKx/fTvlPjbraPNU4OzwPjVLqXBzpoXYhBquPaZYFRVyvfFZLObYsmy+BrsxcloM +l+9w4P0ATJ9njB7dRDL+RrN4uhhYSihqOK4w4vaiOj1+aA0eC0zXunEfLXfGIVQ/FhWcCy +5f72mMiKnQAAA9AxSmzFMUpsxQAAAAdzc2gtcnNhAAABAQCqOZB5vkRvXFXups1/0StDRd +G8plbNSwsWEnNnP4Bvurxa0+z3W9B8GLKnDiLw5MbpbMNyBlpXw13GfuIeciy10DWTz0xU +biy3J3KabCaT36asIw2y7k6Z0jL0UBnrVENwq5/lUbZYqSZ4rRU744wkhh8TULpzM14npQ +CZwg6aEbG+MwjzddQ72fR+3BPBrKn5dTmmu8rH99O+U+Nuto81Tg7PA+NUupcHOmhdiEGq +49plgVFXK98Vks5tiybL4GuzFyWgyX73Dg/QBMn2eMHt1EMv5Gs3i6GFhKKGo4rjDi9qI6 +PX5oDR4LTNe6cR8td8YhVD8WFZwLLl/vaYyIqdAAAAAwEAAQAAAQBz+nyBNi2SYir6SxPA +flcnoq5gBkUl4ndPNosCUbXEakpi5/mQHzJRGtK+F1efIYCVEdGoIsPy/90onNKbQ9dKmO +2oI5kx/U7iCzJ+HCm8nqkEp21x+AP9scWdx+Wg/OxmG8j5iU7f4X+gwOyyvTqCuA78Lgia +7Oi9wiJCoIEqXr6dRYGJzfASwKA2dj995HzATexleLSD5fQCmZTF+Vh5OQ5WmE+c53JdZS +T3Plie/P/smgSWBtf1fWr6JL2+EBsqQsIK1Jo7r/7rxsz+ILoVfnneNQY4QSa9W+t6ZAI+ +caSA0Guv7vC92ewjlMVlwKa3XaEjMJb5sFlg1r6TYMwBAAAAgQDQwXvgSXNaSHIeH53/Ab +t4BlNibtxK8vY8CZFloAKXkjrivKSlDAmQCM0twXOweX2ScPjE+XlSMV4AUsv/J6XHGHci +W3+PGIBfc/fQRBpiyhzkoXYDVrlkSKHffCnAqTUQlYkhr0s7NkZpEeqPE0doAUs4dK3Iqb +zdtz8e5BPXZwAAAIEA4U/JskIu5Oge8Is2OLOhlol0EJGw5JGodpFyhbMC+QYK9nYqy7wI +a6mZ2EfOjjwIZD/+wYyulw6cRve4zXwgzUEXLIKp8/H3sYvJK2UMeP7y68sQFqGxbm6Rnh +tyBBSaJQnOXVOFf9gqZGCyO/J0Illg3AXTuC8KS/cxwasC38EAAACBAMFo/6XQoR6E3ynj +VBaz2SilWqQBixUyvcNz8LY73IIDCecoccRMFSEKhWtvlJijxvFbF9M8g9oKAVPuub4V5r +CGmwVPEd5yt4C2iyV0PhLp1PA2/i42FpCSnHaz/EXSz6ncTZcOMMuDqUbgUUpQg4VSUDl9 +fhTNAzWwZoQ91aHdAAAAFHUwMDIyMTQ2QGljdHMtcC1ueC03AQIDBAUG +-----END OPENSSH PRIVATE KEY-----` + httpsigCertificate = `ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgiR7SU8gmZLhopx4Y03nOXVuAb+4fyMcJYjMGcE1Z2oEAAAADAQABAAABAQCqOZB5vkRvXFXups1/0StDRdG8plbNSwsWEnNnP4Bvurxa0+z3W9B8GLKnDiLw5MbpbMNyBlpXw13GfuIeciy10DWTz0xUbiy3J3KabCaT36asIw2y7k6Z0jL0UBnrVENwq5/lUbZYqSZ4rRU744wkhh8TULpzM14npQCZwg6aEbG+MwjzddQ72fR+3BPBrKn5dTmmu8rH99O+U+Nuto81Tg7PA+NUupcHOmhdiEGq49plgVFXK98Vks5tiybL4GuzFyWgyX73Dg/QBMn2eMHt1EMv5Gs3i6GFhKKGo4rjDi9qI6PX5oDR4LTNe6cR8td8YhVD8WFZwLLl/vaYyIqdAAAAAAAAAAEAAAABAAAABXVzZXIxAAAACQAAAAV1c2VyMQAAAABimoIOAAAAAMCWkRMAAAAAAAAAggAAABVwZXJtaXQtWDExLWZvcndhcmRpbmcAAAAAAAAAF3Blcm1pdC1hZ2VudC1mb3J3YXJkaW5nAAAAAAAAABZwZXJtaXQtcG9ydC1mb3J3YXJkaW5nAAAAAAAAAApwZXJtaXQtcHR5AAAAAAAAAA5wZXJtaXQtdXNlci1yYwAAAAAAAAAAAAABlwAAAAdzc2gtcnNhAAAAAwEAAQAAAYEAm+AwtXTBZyeqV1qOxjMU3Ibc5iR2M3zerGfRQDxUeIozC3xpIvqJbzjDuRapdf8hpxn2xC0GtUusuLIUr4/+Svs1BUnJhF2H9xnK/O0aopS5MpNekUvnBzQdbvO8Ux2xE2mt58giXhkEaXeCEODSqG++OZsA2e40AR/AGRJ4OdDofMvH4vLJAQQc2mKdYpYL8xu+NC+7nsenx1etpsqtEl3gmvqCVI6t9uhVPMvlbGt9h/AN3u7ToF2T3bdk1TZbcdkvR9ljvETIuy32ksAETX8tc7vm30edK+nn/GMeWCgjM+MFm9Uh1NRkvNNJozo5SJy0DkWETTJUsEdfry5VQ3IjqhWqQ0m4/mDlTmsEdEdWqpUiqWZLd9w7jgT8fanuglZyIu2fj8fyqjPjiws5S2P0Uvi28UKQ1nH01UYj/kuakU3BNzN1IqDf3tARP9fjKV/dCBqb1ZAOtyC2GyhGuGzNwEi+woUwq+sTeV0/hqVSb3hSitXHzcfRMRyOK82BAAABlAAAAAxyc2Etc2hhMi01MTIAAAGAMBfgZFvz4BdxriGKYd6eRhMo6hf+I8S9uzNRsflJXHuA+HR9ExIm/Q9JjKmfThQzNyGGBOBILaDU205SAJuG+kk3SieSQDd75ZQd8YmNlCc+516AriOsTiyVCupnf3I2euTjMZqEZbJcBbkBljppTOWQVN7xxE8QakDfGhg0+RjJE9wYOTmkKpDBfII5Nw8V5DoOD7kNEpXYqHdy/8lVxpqUYNIP1J0dNP4f6qBcZcM1PDA12q8zwIGqSNNjf2UXY/Nr8nv9CnK4fB8NDOPKTBa4cm48BGbvM/X0l6dYKswuZ9Np8lw+y6+GxTgznGCrkzMmuEV4FzSq4xHp41H2L2MTwUkwYaeyG1VP6aWkvn6zPkSxaaJDfQX7CAFe17IhIGXR0UPLjKjh35nDLzMWb/W6/W1lK9YkZNHXSf7Z9m9MUAZN7yQgOggGsuYEW4imZxvZizMd+fdDu9mbhr0FDis89I7MSJDnyYRE9FXS7p3QpppBwGcss/9yV3JV3Bjc` +) + +func TestHTTPSigPubKey(t *testing.T) { + // Add our public key to user1 + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.SSH.MinimumKeySizeCheck, false)() + session := loginUser(t, "user1") + token := url.QueryEscape(getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)) + keyType := "ssh-rsa" + keyContent := "AAAAB3NzaC1yc2EAAAADAQABAAABAQCqOZB5vkRvXFXups1/0StDRdG8plbNSwsWEnNnP4Bvurxa0+z3W9B8GLKnDiLw5MbpbMNyBlpXw13GfuIeciy10DWTz0xUbiy3J3KabCaT36asIw2y7k6Z0jL0UBnrVENwq5/lUbZYqSZ4rRU744wkhh8TULpzM14npQCZwg6aEbG+MwjzddQ72fR+3BPBrKn5dTmmu8rH99O+U+Nuto81Tg7PA+NUupcHOmhdiEGq49plgVFXK98Vks5tiybL4GuzFyWgyX73Dg/QBMn2eMHt1EMv5Gs3i6GFhKKGo4rjDi9qI6PX5oDR4LTNe6cR8td8YhVD8WFZwLLl/vaYyIqd" + rawKeyBody := api.CreateKeyOption{ + Title: "test-key", + Key: keyType + " " + keyContent, + } + req := NewRequestWithJSON(t, "POST", "/api/v1/user/keys", rawKeyBody). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // parse our private key and create the httpsig request + sshSigner, _ := ssh.ParsePrivateKey([]byte(httpsigPrivateKey)) + keyID := ssh.FingerprintSHA256(sshSigner.PublicKey()) + + // create the request + token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadAdmin) + req = NewRequest(t, "GET", "/api/v1/admin/users"). + AddTokenAuth(token) + + signer, _, err := httpsig.NewSSHSigner(sshSigner, httpsig.DigestSha512, []string{httpsig.RequestTarget, "(created)", "(expires)"}, httpsig.Signature, 10) + if err != nil { + t.Fatal(err) + } + + // sign the request + err = signer.SignRequest(keyID, req.Request, nil) + if err != nil { + t.Fatal(err) + } + + // make the request + MakeRequest(t, req, http.StatusOK) +} + +func TestHTTPSigCert(t *testing.T) { + // Add our public key to user1 + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user1") + + csrf := GetCSRF(t, session, "/user/settings/keys") + req := NewRequestWithValues(t, "POST", "/user/settings/keys", map[string]string{ + "_csrf": csrf, + "content": "user1", + "title": "principal", + "type": "principal", + }) + + session.MakeRequest(t, req, http.StatusSeeOther) + pkcert, _, _, _, err := ssh.ParseAuthorizedKey([]byte(httpsigCertificate)) + if err != nil { + t.Fatal(err) + } + + // parse our private key and create the httpsig request + sshSigner, _ := ssh.ParsePrivateKey([]byte(httpsigPrivateKey)) + keyID := "gitea" + + // create our certificate signer using the ssh signer and our certificate + certSigner, err := ssh.NewCertSigner(pkcert.(*ssh.Certificate), sshSigner) + if err != nil { + t.Fatal(err) + } + + // create the request + req = NewRequest(t, "GET", "/api/v1/admin/users") + + // add our cert to the request + certString := base64.RawStdEncoding.EncodeToString(pkcert.(*ssh.Certificate).Marshal()) + req.SetHeader("x-ssh-certificate", certString) + + signer, _, err := httpsig.NewSSHSigner(certSigner, httpsig.DigestSha512, []string{httpsig.RequestTarget, "(created)", "(expires)", "x-ssh-certificate"}, httpsig.Signature, 10) + if err != nil { + t.Fatal(err) + } + + // sign the request + err = signer.SignRequest(keyID, req.Request, nil) + if err != nil { + t.Fatal(err) + } + + // make the request + MakeRequest(t, req, http.StatusOK) +} diff --git a/tests/integration/api_issue_attachment_test.go b/tests/integration/api_issue_attachment_test.go new file mode 100644 index 0000000..77e752d --- /dev/null +++ b/tests/integration/api_issue_attachment_test.go @@ -0,0 +1,244 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIGetIssueAttachment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 1}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: attachment.IssueID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue) + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets/%d", repoOwner.Name, repo.Name, issue.Index, attachment.ID)). + AddTokenAuth(token) + resp := session.MakeRequest(t, req, http.StatusOK) + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) +} + +func TestAPIListIssueAttachments(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 1}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: attachment.IssueID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue) + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", repoOwner.Name, repo.Name, issue.Index)). + AddTokenAuth(token) + resp := session.MakeRequest(t, req, http.StatusOK) + apiAttachment := new([]api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: (*apiAttachment)[0].ID, IssueID: issue.ID}) +} + +func TestAPICreateIssueAttachment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + filename := "image.png" + buff := generateImg() + body := &bytes.Buffer{} + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + require.NoError(t, err) + _, err = io.Copy(part, &buff) + require.NoError(t, err) + err = writer.Close() + require.NoError(t, err) + + req := NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", repoOwner.Name, repo.Name, issue.Index), body). + AddTokenAuth(token) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) +} + +func TestAPICreateIssueAttachmentAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", + repoOwner.Name, repo.Name, issue.Index) + + filename := "image.png" + buff := generateImg() + body := &bytes.Buffer{} + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + require.NoError(t, err) + _, err = io.Copy(part, &buff) + require.NoError(t, err) + err = writer.Close() + require.NoError(t, err) + + req := NewRequestWithBody(t, "POST", urlStr, body).AddTokenAuth(token) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(apiAttachment.Created) + assert.LessOrEqual(t, updatedSince, time.Minute) + + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.Index}) + updatedSince = time.Since(issueAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + urlStr += fmt.Sprintf("?updated_at=%s", updatedAt.UTC().Format(time.RFC3339)) + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + require.NoError(t, err) + _, err = io.Copy(part, &buff) + require.NoError(t, err) + err = writer.Close() + require.NoError(t, err) + + req := NewRequestWithBody(t, "POST", urlStr, body).AddTokenAuth(token) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) + assert.Equal(t, updatedAt.In(utcTZ), apiAttachment.Created.In(utcTZ)) + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.ID}) + assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) +} + +func TestAPICreateIssueAttachmentWithUnallowedFile(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + filename := "file.bad" + body := &bytes.Buffer{} + + // Setup multi-part. + writer := multipart.NewWriter(body) + _, err := writer.CreateFormFile("attachment", filename) + require.NoError(t, err) + err = writer.Close() + require.NoError(t, err) + + req := NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", repoOwner.Name, repo.Name, issue.Index), body). + AddTokenAuth(token) + req.Header.Add("Content-Type", writer.FormDataContentType()) + + session.MakeRequest(t, req, http.StatusUnprocessableEntity) +} + +func TestAPIEditIssueAttachment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + const newAttachmentName = "newAttachmentName" + + attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 1}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: attachment.IssueID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets/%d", + repoOwner.Name, repo.Name, issue.Index, attachment.ID) + req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{ + "name": newAttachmentName, + }).AddTokenAuth(token) + resp := session.MakeRequest(t, req, http.StatusCreated) + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID, Name: apiAttachment.Name}) +} + +func TestAPIDeleteIssueAttachment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 1}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: attachment.IssueID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets/%d", repoOwner.Name, repo.Name, issue.Index, attachment.ID)). + AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertNotExistsBean(t, &repo_model.Attachment{ID: attachment.ID, IssueID: issue.ID}) +} diff --git a/tests/integration/api_issue_config_test.go b/tests/integration/api_issue_config_test.go new file mode 100644 index 0000000..16f81e7 --- /dev/null +++ b/tests/integration/api_issue_config_test.go @@ -0,0 +1,211 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func createIssueConfigInDirectory(t *testing.T, user *user_model.User, repo *repo_model.Repository, dir string, issueConfig map[string]any) { + config, err := yaml.Marshal(issueConfig) + require.NoError(t, err) + + err = createOrReplaceFileInBranch(user, repo, fmt.Sprintf("%s/ISSUE_TEMPLATE/config.yaml", dir), repo.DefaultBranch, string(config)) + require.NoError(t, err) +} + +func createIssueConfig(t *testing.T, user *user_model.User, repo *repo_model.Repository, issueConfig map[string]any) { + createIssueConfigInDirectory(t, user, repo, ".gitea", issueConfig) +} + +func getIssueConfig(t *testing.T, owner, repo string) api.IssueConfig { + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issue_config", owner, repo) + req := NewRequest(t, "GET", urlStr) + resp := MakeRequest(t, req, http.StatusOK) + + var issueConfig api.IssueConfig + DecodeJSON(t, resp, &issueConfig) + + return issueConfig +} + +func TestAPIRepoGetIssueConfig(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 49}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + t.Run("Default", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + issueConfig := getIssueConfig(t, owner.Name, repo.Name) + + assert.True(t, issueConfig.BlankIssuesEnabled) + assert.Empty(t, issueConfig.ContactLinks) + }) + + t.Run("DisableBlankIssues", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + config := make(map[string]any) + config["blank_issues_enabled"] = false + + createIssueConfig(t, owner, repo, config) + + issueConfig := getIssueConfig(t, owner.Name, repo.Name) + + assert.False(t, issueConfig.BlankIssuesEnabled) + assert.Empty(t, issueConfig.ContactLinks) + }) + + t.Run("ContactLinks", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + contactLink := make(map[string]string) + contactLink["name"] = "TestName" + contactLink["url"] = "https://example.com" + contactLink["about"] = "TestAbout" + + config := make(map[string]any) + config["contact_links"] = []map[string]string{contactLink} + + createIssueConfig(t, owner, repo, config) + + issueConfig := getIssueConfig(t, owner.Name, repo.Name) + + assert.True(t, issueConfig.BlankIssuesEnabled) + assert.Len(t, issueConfig.ContactLinks, 1) + + assert.Equal(t, "TestName", issueConfig.ContactLinks[0].Name) + assert.Equal(t, "https://example.com", issueConfig.ContactLinks[0].URL) + assert.Equal(t, "TestAbout", issueConfig.ContactLinks[0].About) + }) + + t.Run("Full", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + contactLink := make(map[string]string) + contactLink["name"] = "TestName" + contactLink["url"] = "https://example.com" + contactLink["about"] = "TestAbout" + + config := make(map[string]any) + config["blank_issues_enabled"] = false + config["contact_links"] = []map[string]string{contactLink} + + createIssueConfig(t, owner, repo, config) + + issueConfig := getIssueConfig(t, owner.Name, repo.Name) + + assert.False(t, issueConfig.BlankIssuesEnabled) + assert.Len(t, issueConfig.ContactLinks, 1) + + assert.Equal(t, "TestName", issueConfig.ContactLinks[0].Name) + assert.Equal(t, "https://example.com", issueConfig.ContactLinks[0].URL) + assert.Equal(t, "TestAbout", issueConfig.ContactLinks[0].About) + }) +} + +func TestAPIRepoIssueConfigPaths(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 49}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + templateConfigCandidates := []string{ + ".forgejo/ISSUE_TEMPLATE/config", + ".forgejo/issue_template/config", + ".gitea/ISSUE_TEMPLATE/config", + ".gitea/issue_template/config", + ".github/ISSUE_TEMPLATE/config", + ".github/issue_template/config", + } + + for _, candidate := range templateConfigCandidates { + for _, extension := range []string{".yaml", ".yml"} { + fullPath := candidate + extension + t.Run(fullPath, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + configMap := make(map[string]any) + configMap["blank_issues_enabled"] = false + + configData, err := yaml.Marshal(configMap) + require.NoError(t, err) + + _, err = createFileInBranch(owner, repo, fullPath, repo.DefaultBranch, string(configData)) + require.NoError(t, err) + + issueConfig := getIssueConfig(t, owner.Name, repo.Name) + + assert.False(t, issueConfig.BlankIssuesEnabled) + assert.Empty(t, issueConfig.ContactLinks) + + _, err = deleteFileInBranch(owner, repo, fullPath, repo.DefaultBranch) + require.NoError(t, err) + }) + } + } +} + +func TestAPIRepoValidateIssueConfig(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 49}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issue_config/validate", owner.Name, repo.Name) + + t.Run("Valid", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", urlStr) + resp := MakeRequest(t, req, http.StatusOK) + + var issueConfigValidation api.IssueConfigValidation + DecodeJSON(t, resp, &issueConfigValidation) + + assert.True(t, issueConfigValidation.Valid) + assert.Empty(t, issueConfigValidation.Message) + }) + + t.Run("Invalid", func(t *testing.T) { + dirs := []string{".gitea", ".forgejo"} + for _, dir := range dirs { + t.Run(dir, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer func() { + deleteFileInBranch(owner, repo, fmt.Sprintf("%s/ISSUE_TEMPLATE/config.yaml", dir), repo.DefaultBranch) + }() + + config := make(map[string]any) + config["blank_issues_enabled"] = "Test" + + createIssueConfigInDirectory(t, owner, repo, dir, config) + + req := NewRequest(t, "GET", urlStr) + resp := MakeRequest(t, req, http.StatusOK) + + var issueConfigValidation api.IssueConfigValidation + DecodeJSON(t, resp, &issueConfigValidation) + + assert.False(t, issueConfigValidation.Valid) + assert.NotEmpty(t, issueConfigValidation.Message) + }) + } + }) +} diff --git a/tests/integration/api_issue_label_test.go b/tests/integration/api_issue_label_test.go new file mode 100644 index 0000000..29da419 --- /dev/null +++ b/tests/integration/api_issue_label_test.go @@ -0,0 +1,309 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "strings" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIModifyLabels(t *testing.T) { + require.NoError(t, unittest.LoadFixtures()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner.Name, repo.Name) + + // CreateLabel + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateLabelOption{ + Name: "TestL 1", + Color: "abcdef", + Description: "test label", + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + apiLabel := new(api.Label) + DecodeJSON(t, resp, &apiLabel) + dbLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: apiLabel.ID, RepoID: repo.ID}) + assert.EqualValues(t, dbLabel.Name, apiLabel.Name) + assert.EqualValues(t, strings.TrimLeft(dbLabel.Color, "#"), apiLabel.Color) + + req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateLabelOption{ + Name: "TestL 2", + Color: "#123456", + Description: "jet another test label", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateLabelOption{ + Name: "WrongTestL", + Color: "#12345g", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + // ListLabels + req = NewRequest(t, "GET", urlStr). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var apiLabels []*api.Label + DecodeJSON(t, resp, &apiLabels) + assert.Len(t, apiLabels, 2) + + // GetLabel + singleURLStr := fmt.Sprintf("/api/v1/repos/%s/%s/labels/%d", owner.Name, repo.Name, dbLabel.ID) + req = NewRequest(t, "GET", singleURLStr). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiLabel) + assert.EqualValues(t, strings.TrimLeft(dbLabel.Color, "#"), apiLabel.Color) + + // EditLabel + newName := "LabelNewName" + newColor := "09876a" + newColorWrong := "09g76a" + req = NewRequestWithJSON(t, "PATCH", singleURLStr, &api.EditLabelOption{ + Name: &newName, + Color: &newColor, + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiLabel) + assert.EqualValues(t, newColor, apiLabel.Color) + req = NewRequestWithJSON(t, "PATCH", singleURLStr, &api.EditLabelOption{ + Color: &newColorWrong, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + // DeleteLabel + req = NewRequest(t, "DELETE", singleURLStr). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) +} + +func TestAPIAddIssueLabels(t *testing.T) { + require.NoError(t, unittest.LoadFixtures()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + _ = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{RepoID: repo.ID, ID: 2}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", + repo.OwnerName, repo.Name, issue.Index) + req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{ + Labels: []any{1, 2}, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiLabels []*api.Label + DecodeJSON(t, resp, &apiLabels) + assert.Len(t, apiLabels, unittest.GetCount(t, &issues_model.IssueLabel{IssueID: issue.ID})) + + unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: 2}) +} + +func TestAPIAddIssueLabelsWithLabelNames(t *testing.T) { + require.NoError(t, unittest.LoadFixtures()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", + repo.OwnerName, repo.Name, issue.Index) + req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{ + Labels: []any{"label1", "label2"}, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiLabels []*api.Label + DecodeJSON(t, resp, &apiLabels) + assert.Len(t, apiLabels, unittest.GetCount(t, &issues_model.IssueLabel{IssueID: issue.ID})) + + var apiLabelNames []string + for _, label := range apiLabels { + apiLabelNames = append(apiLabelNames, label.Name) + } + assert.ElementsMatch(t, apiLabelNames, []string{"label1", "label2"}) +} + +func TestAPIAddIssueLabelsAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", + owner.Name, repo.Name, issueBefore.Index) + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{ + Labels: []any{1}, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueBefore.ID}) + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(issueAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithUpdatedDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{ + Labels: []any{2}, + Updated: &updatedAt, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueBefore.ID}) + assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) +} + +func TestAPIReplaceIssueLabels(t *testing.T) { + require.NoError(t, unittest.LoadFixtures()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{RepoID: repo.ID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", + owner.Name, repo.Name, issue.Index) + req := NewRequestWithJSON(t, "PUT", urlStr, &api.IssueLabelsOption{ + Labels: []any{label.ID}, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiLabels []*api.Label + DecodeJSON(t, resp, &apiLabels) + if assert.Len(t, apiLabels, 1) { + assert.EqualValues(t, label.ID, apiLabels[0].ID) + } + + unittest.AssertCount(t, &issues_model.IssueLabel{IssueID: issue.ID}, 1) + unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: label.ID}) +} + +func TestAPIReplaceIssueLabelsWithLabelNames(t *testing.T) { + require.NoError(t, unittest.LoadFixtures()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{RepoID: repo.ID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", + owner.Name, repo.Name, issue.Index) + req := NewRequestWithJSON(t, "PUT", urlStr, &api.IssueLabelsOption{ + Labels: []any{label.Name}, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiLabels []*api.Label + DecodeJSON(t, resp, &apiLabels) + if assert.Len(t, apiLabels, 1) { + assert.EqualValues(t, label.Name, apiLabels[0].Name) + } +} + +func TestAPIModifyOrgLabels(t *testing.T) { + require.NoError(t, unittest.LoadFixtures()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + user := "user1" + session := loginUser(t, user) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteOrganization) + urlStr := fmt.Sprintf("/api/v1/orgs/%s/labels", owner.Name) + + // CreateLabel + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateLabelOption{ + Name: "TestL 1", + Color: "abcdef", + Description: "test label", + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + apiLabel := new(api.Label) + DecodeJSON(t, resp, &apiLabel) + dbLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: apiLabel.ID, OrgID: owner.ID}) + assert.EqualValues(t, dbLabel.Name, apiLabel.Name) + assert.EqualValues(t, strings.TrimLeft(dbLabel.Color, "#"), apiLabel.Color) + + req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateLabelOption{ + Name: "TestL 2", + Color: "#123456", + Description: "jet another test label", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateLabelOption{ + Name: "WrongTestL", + Color: "#12345g", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + // ListLabels + req = NewRequest(t, "GET", urlStr). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var apiLabels []*api.Label + DecodeJSON(t, resp, &apiLabels) + assert.Len(t, apiLabels, 4) + + // GetLabel + singleURLStr := fmt.Sprintf("/api/v1/orgs/%s/labels/%d", owner.Name, dbLabel.ID) + req = NewRequest(t, "GET", singleURLStr). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiLabel) + assert.EqualValues(t, strings.TrimLeft(dbLabel.Color, "#"), apiLabel.Color) + + // EditLabel + newName := "LabelNewName" + newColor := "09876a" + newColorWrong := "09g76a" + req = NewRequestWithJSON(t, "PATCH", singleURLStr, &api.EditLabelOption{ + Name: &newName, + Color: &newColor, + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiLabel) + assert.EqualValues(t, newColor, apiLabel.Color) + req = NewRequestWithJSON(t, "PATCH", singleURLStr, &api.EditLabelOption{ + Color: &newColorWrong, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + // DeleteLabel + req = NewRequest(t, "DELETE", singleURLStr). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) +} diff --git a/tests/integration/api_issue_milestone_test.go b/tests/integration/api_issue_milestone_test.go new file mode 100644 index 0000000..32ac562 --- /dev/null +++ b/tests/integration/api_issue_milestone_test.go @@ -0,0 +1,86 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIIssuesMilestone(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + milestone := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: 1}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: milestone.RepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + assert.Equal(t, int64(1), int64(milestone.NumIssues)) + assert.Equal(t, structs.StateOpen, milestone.State()) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + // update values of issue + milestoneState := "closed" + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/milestones/%d", owner.Name, repo.Name, milestone.ID) + req := NewRequestWithJSON(t, "PATCH", urlStr, structs.EditMilestoneOption{ + State: &milestoneState, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiMilestone structs.Milestone + DecodeJSON(t, resp, &apiMilestone) + assert.EqualValues(t, "closed", apiMilestone.State) + + req = NewRequest(t, "GET", urlStr). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var apiMilestone2 structs.Milestone + DecodeJSON(t, resp, &apiMilestone2) + assert.EqualValues(t, "closed", apiMilestone2.State) + + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/milestones", owner.Name, repo.Name), structs.CreateMilestoneOption{ + Title: "wow", + Description: "closed one", + State: "closed", + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, &apiMilestone) + assert.Equal(t, "wow", apiMilestone.Title) + assert.Equal(t, structs.StateClosed, apiMilestone.State) + + var apiMilestones []structs.Milestone + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/milestones?state=%s", owner.Name, repo.Name, "all")). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiMilestones) + assert.Len(t, apiMilestones, 4) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/milestones/%s", owner.Name, repo.Name, apiMilestones[2].Title)). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiMilestone) + assert.EqualValues(t, apiMilestones[2], apiMilestone) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/milestones?state=%s&name=%s", owner.Name, repo.Name, "all", "milestone2")). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiMilestones) + assert.Len(t, apiMilestones, 1) + assert.Equal(t, int64(2), apiMilestones[0].ID) + + req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/milestones/%d", owner.Name, repo.Name, apiMilestone.ID)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) +} diff --git a/tests/integration/api_issue_pin_test.go b/tests/integration/api_issue_pin_test.go new file mode 100644 index 0000000..2f257a8 --- /dev/null +++ b/tests/integration/api_issue_pin_test.go @@ -0,0 +1,190 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIPinIssue(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + require.NoError(t, unittest.LoadFixtures()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + // Pin the Issue + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", repo.OwnerName, repo.Name, issue.Index)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // Check if the Issue is pinned + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index)) + resp := MakeRequest(t, req, http.StatusOK) + var issueAPI api.Issue + DecodeJSON(t, resp, &issueAPI) + assert.Equal(t, 1, issueAPI.PinOrder) +} + +func TestAPIUnpinIssue(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + require.NoError(t, unittest.LoadFixtures()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + // Pin the Issue + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", repo.OwnerName, repo.Name, issue.Index)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // Check if the Issue is pinned + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index)) + resp := MakeRequest(t, req, http.StatusOK) + var issueAPI api.Issue + DecodeJSON(t, resp, &issueAPI) + assert.Equal(t, 1, issueAPI.PinOrder) + + // Unpin the Issue + req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", repo.OwnerName, repo.Name, issue.Index)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // Check if the Issue is no longer pinned + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index)) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &issueAPI) + assert.Equal(t, 0, issueAPI.PinOrder) +} + +func TestAPIMoveIssuePin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + require.NoError(t, unittest.LoadFixtures()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2, RepoID: repo.ID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + // Pin the first Issue + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", repo.OwnerName, repo.Name, issue.Index)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // Check if the first Issue is pinned at position 1 + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index)) + resp := MakeRequest(t, req, http.StatusOK) + var issueAPI api.Issue + DecodeJSON(t, resp, &issueAPI) + assert.Equal(t, 1, issueAPI.PinOrder) + + // Pin the second Issue + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", repo.OwnerName, repo.Name, issue2.Index)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // Move the first Issue to position 2 + req = NewRequest(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin/2", repo.OwnerName, repo.Name, issue.Index)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // Check if the first Issue is pinned at position 2 + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index)) + resp = MakeRequest(t, req, http.StatusOK) + var issueAPI3 api.Issue + DecodeJSON(t, resp, &issueAPI3) + assert.Equal(t, 2, issueAPI3.PinOrder) + + // Check if the second Issue is pinned at position 1 + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue2.Index)) + resp = MakeRequest(t, req, http.StatusOK) + var issueAPI4 api.Issue + DecodeJSON(t, resp, &issueAPI4) + assert.Equal(t, 1, issueAPI4.PinOrder) +} + +func TestAPIListPinnedIssues(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + require.NoError(t, unittest.LoadFixtures()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + // Pin the Issue + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", repo.OwnerName, repo.Name, issue.Index)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // Check if the Issue is in the List + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/pinned", repo.OwnerName, repo.Name)) + resp := MakeRequest(t, req, http.StatusOK) + var issueList []api.Issue + DecodeJSON(t, resp, &issueList) + + assert.Len(t, issueList, 1) + assert.Equal(t, issue.ID, issueList[0].ID) +} + +func TestAPIListPinnedPullrequests(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + require.NoError(t, unittest.LoadFixtures()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/pinned", repo.OwnerName, repo.Name)) + resp := MakeRequest(t, req, http.StatusOK) + var prList []api.PullRequest + DecodeJSON(t, resp, &prList) + + assert.Empty(t, prList) +} + +func TestAPINewPinAllowed(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/new_pin_allowed", owner.Name, repo.Name)) + resp := MakeRequest(t, req, http.StatusOK) + + var newPinsAllowed api.NewIssuePinsAllowed + DecodeJSON(t, resp, &newPinsAllowed) + + assert.True(t, newPinsAllowed.Issues) + assert.True(t, newPinsAllowed.PullRequests) +} diff --git a/tests/integration/api_issue_reaction_test.go b/tests/integration/api_issue_reaction_test.go new file mode 100644 index 0000000..4ca909f --- /dev/null +++ b/tests/integration/api_issue_reaction_test.go @@ -0,0 +1,165 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIIssuesReactions(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + _ = issue.LoadRepo(db.DefaultContext) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: issue.Repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/reactions", owner.Name, issue.Repo.Name, issue.Index) + + // Try to add not allowed reaction + req := NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{ + Reaction: "wrong", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + // Delete not allowed reaction + req = NewRequestWithJSON(t, "DELETE", urlStr, &api.EditReactionOption{ + Reaction: "zzz", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + // Add allowed reaction + req = NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{ + Reaction: "rocket", + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var apiNewReaction api.Reaction + DecodeJSON(t, resp, &apiNewReaction) + + // Add existing reaction + MakeRequest(t, req, http.StatusForbidden) + + // Get end result of reaction list of issue #1 + req = NewRequest(t, "GET", urlStr). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var apiReactions []*api.Reaction + DecodeJSON(t, resp, &apiReactions) + expectResponse := make(map[int]api.Reaction) + expectResponse[0] = api.Reaction{ + User: convert.ToUser(db.DefaultContext, user2, user2), + Reaction: "eyes", + Created: time.Unix(1573248003, 0), + } + expectResponse[1] = apiNewReaction + assert.Len(t, apiReactions, 2) + for i, r := range apiReactions { + assert.Equal(t, expectResponse[i].Reaction, r.Reaction) + assert.Equal(t, expectResponse[i].Created.Unix(), r.Created.Unix()) + assert.Equal(t, expectResponse[i].User.ID, r.User.ID) + } +} + +func TestAPICommentReactions(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) + _ = comment.LoadIssue(db.DefaultContext) + issue := comment.Issue + _ = issue.LoadRepo(db.DefaultContext) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: issue.Repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/reactions", owner.Name, issue.Repo.Name, comment.ID) + + // Try to add not allowed reaction + req := NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{ + Reaction: "wrong", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + // Delete none existing reaction + req = NewRequestWithJSON(t, "DELETE", urlStr, &api.EditReactionOption{ + Reaction: "eyes", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + t.Run("UnrelatedCommentID", func(t *testing.T) { + // Using the ID of a comment that does not belong to the repository must fail + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/reactions", repoOwner.Name, repo.Name, comment.ID) + req = NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{ + Reaction: "+1", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + req = NewRequestWithJSON(t, "DELETE", urlStr, &api.EditReactionOption{ + Reaction: "+1", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", urlStr). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) + + // Add allowed reaction + req = NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{ + Reaction: "+1", + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var apiNewReaction api.Reaction + DecodeJSON(t, resp, &apiNewReaction) + + // Add existing reaction + MakeRequest(t, req, http.StatusForbidden) + + // Get end result of reaction list of issue #1 + req = NewRequest(t, "GET", urlStr). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var apiReactions []*api.Reaction + DecodeJSON(t, resp, &apiReactions) + expectResponse := make(map[int]api.Reaction) + expectResponse[0] = api.Reaction{ + User: convert.ToUser(db.DefaultContext, user2, user2), + Reaction: "laugh", + Created: time.Unix(1573248004, 0), + } + expectResponse[1] = api.Reaction{ + User: convert.ToUser(db.DefaultContext, user1, user1), + Reaction: "laugh", + Created: time.Unix(1573248005, 0), + } + expectResponse[2] = apiNewReaction + assert.Len(t, apiReactions, 3) + for i, r := range apiReactions { + assert.Equal(t, expectResponse[i].Reaction, r.Reaction) + assert.Equal(t, expectResponse[i].Created.Unix(), r.Created.Unix()) + assert.Equal(t, expectResponse[i].User.ID, r.User.ID) + } +} diff --git a/tests/integration/api_issue_stopwatch_test.go b/tests/integration/api_issue_stopwatch_test.go new file mode 100644 index 0000000..4765787 --- /dev/null +++ b/tests/integration/api_issue_stopwatch_test.go @@ -0,0 +1,96 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIListStopWatches(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopeReadUser) + req := NewRequest(t, "GET", "/api/v1/user/stopwatches"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiWatches []*api.StopWatch + DecodeJSON(t, resp, &apiWatches) + stopwatch := unittest.AssertExistsAndLoadBean(t, &issues_model.Stopwatch{UserID: owner.ID}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: stopwatch.IssueID}) + if assert.Len(t, apiWatches, 1) { + assert.EqualValues(t, stopwatch.CreatedUnix.AsTime().Unix(), apiWatches[0].Created.Unix()) + assert.EqualValues(t, issue.Index, apiWatches[0].IssueIndex) + assert.EqualValues(t, issue.Title, apiWatches[0].IssueTitle) + assert.EqualValues(t, repo.Name, apiWatches[0].RepoName) + assert.EqualValues(t, repo.OwnerName, apiWatches[0].RepoOwnerName) + assert.Positive(t, apiWatches[0].Seconds) + } +} + +func TestAPIStopStopWatches(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) + _ = issue.LoadRepo(db.DefaultContext) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: issue.Repo.OwnerID}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/issues/%d/stopwatch/stop", owner.Name, issue.Repo.Name, issue.Index). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + MakeRequest(t, req, http.StatusConflict) +} + +func TestAPICancelStopWatches(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + _ = issue.LoadRepo(db.DefaultContext) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: issue.Repo.OwnerID}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/issues/%d/stopwatch/delete", owner.Name, issue.Repo.Name, issue.Index). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + MakeRequest(t, req, http.StatusConflict) +} + +func TestAPIStartStopWatches(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) + _ = issue.LoadRepo(db.DefaultContext) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: issue.Repo.OwnerID}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/issues/%d/stopwatch/start", owner.Name, issue.Repo.Name, issue.Index). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + MakeRequest(t, req, http.StatusConflict) +} diff --git a/tests/integration/api_issue_subscription_test.go b/tests/integration/api_issue_subscription_test.go new file mode 100644 index 0000000..7a71630 --- /dev/null +++ b/tests/integration/api_issue_subscription_test.go @@ -0,0 +1,82 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIIssueSubscriptions(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) + issue3 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) + issue4 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4}) + issue5 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 8}) + + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: issue1.PosterID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + testSubscription := func(issue *issues_model.Issue, isWatching bool) { + issueRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/subscriptions/check", issueRepo.OwnerName, issueRepo.Name, issue.Index)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + wi := new(api.WatchInfo) + DecodeJSON(t, resp, wi) + + assert.EqualValues(t, isWatching, wi.Subscribed) + assert.EqualValues(t, !isWatching, wi.Ignored) + assert.EqualValues(t, issue.APIURL(db.DefaultContext)+"/subscriptions", wi.URL) + assert.EqualValues(t, issue.CreatedUnix, wi.CreatedAt.Unix()) + assert.EqualValues(t, issueRepo.APIURL(), wi.RepositoryURL) + } + + testSubscription(issue1, true) + testSubscription(issue2, true) + testSubscription(issue3, true) + testSubscription(issue4, false) + testSubscription(issue5, false) + + issue1Repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue1.RepoID}) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/subscriptions/%s", issue1Repo.OwnerName, issue1Repo.Name, issue1.Index, owner.Name) + req := NewRequest(t, "DELETE", urlStr). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + testSubscription(issue1, false) + + req = NewRequest(t, "DELETE", urlStr). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + testSubscription(issue1, false) + + issue5Repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue5.RepoID}) + urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/subscriptions/%s", issue5Repo.OwnerName, issue5Repo.Name, issue5.Index, owner.Name) + req = NewRequest(t, "PUT", urlStr). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + testSubscription(issue5, true) + + req = NewRequest(t, "PUT", urlStr). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + testSubscription(issue5, true) +} diff --git a/tests/integration/api_issue_templates_test.go b/tests/integration/api_issue_templates_test.go new file mode 100644 index 0000000..d634329 --- /dev/null +++ b/tests/integration/api_issue_templates_test.go @@ -0,0 +1,115 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIIssueTemplateList(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + t.Run("no templates", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/issue_templates", repo.FullName())) + resp := MakeRequest(t, req, http.StatusOK) + var issueTemplates []*api.IssueTemplate + DecodeJSON(t, resp, &issueTemplates) + assert.Empty(t, issueTemplates) + }) + + t.Run("existing template", func(t *testing.T) { + templateCandidates := []string{ + ".forgejo/ISSUE_TEMPLATE/test.md", + ".forgejo/issue_template/test.md", + ".gitea/ISSUE_TEMPLATE/test.md", + ".gitea/issue_template/test.md", + ".github/ISSUE_TEMPLATE/test.md", + ".github/issue_template/test.md", + } + + for _, template := range templateCandidates { + t.Run(template, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer func() { + deleteFileInBranch(user, repo, template, repo.DefaultBranch) + }() + + err := createOrReplaceFileInBranch(user, repo, template, repo.DefaultBranch, + `--- +name: 'Template Name' +about: 'This template is for testing!' +title: '[TEST] ' +ref: 'main' +--- + +This is the template!`) + require.NoError(t, err) + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/issue_templates", repo.FullName())) + resp := MakeRequest(t, req, http.StatusOK) + var issueTemplates []*api.IssueTemplate + DecodeJSON(t, resp, &issueTemplates) + assert.Len(t, issueTemplates, 1) + assert.Equal(t, "Template Name", issueTemplates[0].Name) + assert.Equal(t, "This template is for testing!", issueTemplates[0].About) + assert.Equal(t, "refs/heads/main", issueTemplates[0].Ref) + assert.Equal(t, template, issueTemplates[0].FileName) + }) + } + }) + + t.Run("multiple templates", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + templatePriority := []string{ + ".forgejo/issue_template/test.md", + ".gitea/issue_template/test.md", + ".github/issue_template/test.md", + } + defer func() { + for _, template := range templatePriority { + deleteFileInBranch(user, repo, template, repo.DefaultBranch) + } + }() + + for _, template := range templatePriority { + err := createOrReplaceFileInBranch(user, repo, template, repo.DefaultBranch, + `--- +name: 'Template Name' +about: 'This template is for testing!' +title: '[TEST] ' +ref: 'main' +--- + +This is the template!`) + require.NoError(t, err) + } + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/issue_templates", repo.FullName())) + resp := MakeRequest(t, req, http.StatusOK) + var issueTemplates []*api.IssueTemplate + DecodeJSON(t, resp, &issueTemplates) + + // If templates have the same filename and content, but in different + // directories, they count as different templates, and all are + // considered. + assert.Len(t, issueTemplates, 3) + }) + }) +} diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go new file mode 100644 index 0000000..4051f95 --- /dev/null +++ b/tests/integration/api_issue_test.go @@ -0,0 +1,578 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + "sync" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIListIssues(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue) + link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner.Name, repo.Name)) + + link.RawQuery = url.Values{"token": {token}, "state": {"all"}}.Encode() + resp := MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK) + var apiIssues []*api.Issue + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, unittest.GetCount(t, &issues_model.Issue{RepoID: repo.ID})) + for _, apiIssue := range apiIssues { + unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: apiIssue.ID, RepoID: repo.ID}) + } + + // test milestone filter + link.RawQuery = url.Values{"token": {token}, "state": {"all"}, "type": {"all"}, "milestones": {"ignore,milestone1,3,4"}}.Encode() + resp = MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + if assert.Len(t, apiIssues, 2) { + assert.EqualValues(t, 3, apiIssues[0].Milestone.ID) + assert.EqualValues(t, 1, apiIssues[1].Milestone.ID) + } + + link.RawQuery = url.Values{"token": {token}, "state": {"all"}, "created_by": {"user2"}}.Encode() + resp = MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + if assert.Len(t, apiIssues, 1) { + assert.EqualValues(t, 5, apiIssues[0].ID) + } + + link.RawQuery = url.Values{"token": {token}, "state": {"all"}, "assigned_by": {"user1"}}.Encode() + resp = MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + if assert.Len(t, apiIssues, 1) { + assert.EqualValues(t, 1, apiIssues[0].ID) + } + + link.RawQuery = url.Values{"token": {token}, "state": {"all"}, "mentioned_by": {"user4"}}.Encode() + resp = MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + if assert.Len(t, apiIssues, 1) { + assert.EqualValues(t, 1, apiIssues[0].ID) + } +} + +func TestAPIListIssuesPublicOnly(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo1.OwnerID}) + + session := loginUser(t, owner1.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue) + link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner1.Name, repo1.Name)) + link.RawQuery = url.Values{"state": {"all"}}.Encode() + req := NewRequest(t, "GET", link.String()).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + owner2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo2.OwnerID}) + + session = loginUser(t, owner2.Name) + token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue) + link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner2.Name, repo2.Name)) + link.RawQuery = url.Values{"state": {"all"}}.Encode() + req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopePublicOnly) + req = NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken) + MakeRequest(t, req, http.StatusForbidden) +} + +func TestAPICreateIssue(t *testing.T) { + defer tests.PrepareTestEnv(t)() + const body, title = "apiTestBody", "apiTestTitle" + + repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues?state=all", owner.Name, repoBefore.Name) + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{ + Body: body, + Title: title, + Assignee: owner.Name, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + assert.Equal(t, body, apiIssue.Body) + assert.Equal(t, title, apiIssue.Title) + + unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ + RepoID: repoBefore.ID, + AssigneeID: owner.ID, + Content: body, + Title: title, + }) + + repoAfter := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + assert.Equal(t, repoBefore.NumIssues+1, repoAfter.NumIssues) + assert.Equal(t, repoBefore.NumClosedIssues, repoAfter.NumClosedIssues) +} + +func TestAPICreateIssueParallel(t *testing.T) { + defer tests.PrepareTestEnv(t)() + const body, title = "apiTestBody", "apiTestTitle" + + repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues?state=all", owner.Name, repoBefore.Name) + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func(parentT *testing.T, i int) { + parentT.Run(fmt.Sprintf("ParallelCreateIssue_%d", i), func(t *testing.T) { + newTitle := title + strconv.Itoa(i) + newBody := body + strconv.Itoa(i) + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{ + Body: newBody, + Title: newTitle, + Assignee: owner.Name, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + assert.Equal(t, newBody, apiIssue.Body) + assert.Equal(t, newTitle, apiIssue.Title) + + unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ + RepoID: repoBefore.ID, + AssigneeID: owner.ID, + Content: newBody, + Title: newTitle, + }) + + wg.Done() + }) + }(t, i) + } + wg.Wait() +} + +func TestAPIEditIssue(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10}) + repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID}) + require.NoError(t, issueBefore.LoadAttributes(db.DefaultContext)) + assert.Equal(t, int64(1019307200), int64(issueBefore.DeadlineUnix)) + assert.Equal(t, api.StateOpen, issueBefore.State()) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + // update values of issue + issueState := "closed" + removeDeadline := true + milestone := int64(4) + body := "new content!" + title := "new title from api set" + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner.Name, repoBefore.Name, issueBefore.Index) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + State: &issueState, + RemoveDeadline: &removeDeadline, + Milestone: &milestone, + Body: &body, + Title: title, + + // ToDo change more + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10}) + repoAfter := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) + + // check comment history + unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: issueAfter.ID, OldTitle: issueBefore.Title, NewTitle: title}) + unittest.AssertExistsAndLoadBean(t, &issues_model.ContentHistory{IssueID: issueAfter.ID, ContentText: body, IsFirstCreated: false}) + + // check deleted user + assert.Equal(t, int64(500), issueAfter.PosterID) + require.NoError(t, issueAfter.LoadAttributes(db.DefaultContext)) + assert.Equal(t, int64(-1), issueAfter.PosterID) + assert.Equal(t, int64(-1), issueBefore.PosterID) + assert.Equal(t, int64(-1), apiIssue.Poster.ID) + + // check repo change + assert.Equal(t, repoBefore.NumClosedIssues+1, repoAfter.NumClosedIssues) + + // API response + assert.Equal(t, api.StateClosed, apiIssue.State) + assert.Equal(t, milestone, apiIssue.Milestone.ID) + assert.Equal(t, body, apiIssue.Body) + assert.Nil(t, apiIssue.Deadline) + assert.Equal(t, title, apiIssue.Title) + + // in database + assert.Equal(t, api.StateClosed, issueAfter.State()) + assert.Equal(t, milestone, issueAfter.MilestoneID) + assert.Equal(t, int64(0), int64(issueAfter.DeadlineUnix)) + assert.Equal(t, body, issueAfter.Content) + assert.Equal(t, title, issueAfter.Title) + + // verify the idempotency of state, milestone, body and title changes + req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + State: &issueState, + Milestone: &milestone, + Body: &body, + Title: title, + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusCreated) + var apiIssueIdempotent api.Issue + DecodeJSON(t, resp, &apiIssueIdempotent) + assert.Equal(t, apiIssue.State, apiIssueIdempotent.State) + assert.Equal(t, apiIssue.Milestone.Title, apiIssueIdempotent.Milestone.Title) + assert.Equal(t, apiIssue.Body, apiIssueIdempotent.Body) + assert.Equal(t, apiIssue.Title, apiIssueIdempotent.Title) +} + +func TestAPIEditIssueAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 13}) + repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID}) + require.NoError(t, issueBefore.LoadAttributes(db.DefaultContext)) + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // User2 is not owner, but can update the 'public' issue with auto date + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner.Name, repoBefore.Name, issueBefore.Index) + + body := "new content!" + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Body: &body, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(apiIssue.Updated) + assert.LessOrEqual(t, updatedSince, time.Minute) + + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueBefore.ID}) + updatedSince = time.Since(issueAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // User1 is admin, and so can update the issue without auto date + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner.Name, repoBefore.Name, issueBefore.Index) + + body := "new content, with updated time" + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Body: &body, + Updated: &updatedAt, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + + // dates are converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + assert.Equal(t, updatedAt.In(utcTZ), apiIssue.Updated.In(utcTZ)) + + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueBefore.ID}) + assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) + + t.Run("WithoutPermission", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // User2 is not owner nor admin, and so can't update the issue without auto date + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner.Name, repoBefore.Name, issueBefore.Index) + + body := "new content, with updated time" + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Body: &body, + Updated: &updatedAt, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusForbidden) + var apiError api.APIError + DecodeJSON(t, resp, &apiError) + + assert.Equal(t, "user needs to have admin or owner right", apiError.Message) + }) +} + +func TestAPIEditIssueMilestoneAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) + + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID}) + require.NoError(t, issueBefore.LoadAttributes(db.DefaultContext)) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner.Name, repoBefore.Name, issueBefore.Index) + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + milestone := int64(1) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Milestone: &milestone, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // the execution of the API call supposedly lasted less than one minute + milestoneAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: milestone}) + updatedSince := time.Since(milestoneAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithPostUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Note: the updated_unix field of the test Milestones is set to NULL + // Hence, any date is higher than the Milestone's updated date + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + milestone := int64(2) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Milestone: &milestone, + Updated: &updatedAt, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // the milestone date should be set to 'updatedAt' + // dates are converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + milestoneAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: milestone}) + assert.Equal(t, updatedAt.In(utcTZ), milestoneAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) + + t.Run("WithPastUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Note: This Milestone's updated_unix has been set to Now() by the first subtest + milestone := int64(1) + milestoneBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: milestone}) + + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Milestone: &milestone, + Updated: &updatedAt, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // the milestone date should not change + // dates are converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + milestoneAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: milestone}) + assert.Equal(t, milestoneAfter.UpdatedUnix.AsTime().In(utcTZ), milestoneBefore.UpdatedUnix.AsTime().In(utcTZ)) + }) +} + +func TestAPISearchIssues(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // as this API was used in the frontend, it uses UI page size + expectedIssueCount := 20 // from the fixtures + if expectedIssueCount > setting.UI.IssuePagingNum { + expectedIssueCount = setting.UI.IssuePagingNum + } + + link, _ := url.Parse("/api/v1/repos/issues/search") + token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadIssue) + query := url.Values{} + var apiIssues []*api.Issue + + link.RawQuery = query.Encode() + req := NewRequest(t, "GET", link.String()).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, expectedIssueCount) + + publicOnlyToken := getUserToken(t, "user1", auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopePublicOnly) + req = NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 15) // 15 public issues + + since := "2000-01-01T00:50:01+00:00" // 946687801 + before := time.Unix(999307200, 0).Format(time.RFC3339) + query.Add("since", since) + query.Add("before", before) + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 11) + query.Del("since") + query.Del("before") + + query.Add("state", "closed") + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 2) + + query.Set("state", "all") + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.EqualValues(t, "22", resp.Header().Get("X-Total-Count")) + assert.Len(t, apiIssues, 20) + + query.Add("limit", "10") + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.EqualValues(t, "22", resp.Header().Get("X-Total-Count")) + assert.Len(t, apiIssues, 10) + + query = url.Values{"assigned": {"true"}, "state": {"all"}} + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 2) + + query = url.Values{"milestones": {"milestone1"}, "state": {"all"}} + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 1) + + query = url.Values{"milestones": {"milestone1,milestone3"}, "state": {"all"}} + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 2) + + query = url.Values{"owner": {"user2"}} // user + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 8) + + query = url.Values{"owner": {"org3"}} // organization + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 5) + + query = url.Values{"owner": {"org3"}, "team": {"team1"}} // organization + team + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 2) +} + +func TestAPISearchIssuesWithLabels(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // as this API was used in the frontend, it uses UI page size + expectedIssueCount := 20 // from the fixtures + if expectedIssueCount > setting.UI.IssuePagingNum { + expectedIssueCount = setting.UI.IssuePagingNum + } + + link, _ := url.Parse("/api/v1/repos/issues/search") + token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadIssue) + query := url.Values{} + var apiIssues []*api.Issue + + link.RawQuery = query.Encode() + req := NewRequest(t, "GET", link.String()).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, expectedIssueCount) + + query.Add("labels", "label1") + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 2) + + // multiple labels + query.Set("labels", "label1,label2") + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 2) + + // an org label + query.Set("labels", "orglabel4") + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 1) + + // org and repo label + query.Set("labels", "label2,orglabel4") + query.Add("state", "all") + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 2) + + // org and repo label which share the same issue + query.Set("labels", "label1,orglabel4") + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 2) +} diff --git a/tests/integration/api_issue_tracked_time_test.go b/tests/integration/api_issue_tracked_time_test.go new file mode 100644 index 0000000..90a59fb --- /dev/null +++ b/tests/integration/api_issue_tracked_time_test.go @@ -0,0 +1,131 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIGetTrackedTimes(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) + require.NoError(t, issue2.LoadRepo(db.DefaultContext)) + + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/%d/times", user2.Name, issue2.Repo.Name, issue2.Index). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiTimes api.TrackedTimeList + DecodeJSON(t, resp, &apiTimes) + expect, err := issues_model.GetTrackedTimes(db.DefaultContext, &issues_model.FindTrackedTimesOptions{IssueID: issue2.ID}) + require.NoError(t, err) + assert.Len(t, apiTimes, 3) + + for i, time := range expect { + assert.Equal(t, time.ID, apiTimes[i].ID) + assert.EqualValues(t, issue2.Title, apiTimes[i].Issue.Title) + assert.EqualValues(t, issue2.ID, apiTimes[i].IssueID) + assert.Equal(t, time.Created.Unix(), apiTimes[i].Created.Unix()) + assert.Equal(t, time.Time, apiTimes[i].Time) + user, err := user_model.GetUserByID(db.DefaultContext, time.UserID) + require.NoError(t, err) + assert.Equal(t, user.Name, apiTimes[i].UserName) + } + + // test filter + since := "2000-01-01T00%3A00%3A02%2B00%3A00" // 946684802 + before := "2000-01-01T00%3A00%3A12%2B00%3A00" // 946684812 + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/%d/times?since=%s&before=%s", user2.Name, issue2.Repo.Name, issue2.Index, since, before). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var filterAPITimes api.TrackedTimeList + DecodeJSON(t, resp, &filterAPITimes) + assert.Len(t, filterAPITimes, 2) + assert.Equal(t, int64(3), filterAPITimes[0].ID) + assert.Equal(t, int64(6), filterAPITimes[1].ID) +} + +func TestAPIDeleteTrackedTime(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + time6 := unittest.AssertExistsAndLoadBean(t, &issues_model.TrackedTime{ID: 6}) + issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) + require.NoError(t, issue2.LoadRepo(db.DefaultContext)) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + // Deletion not allowed + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/issues/%d/times/%d", user2.Name, issue2.Repo.Name, issue2.Index, time6.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + time3 := unittest.AssertExistsAndLoadBean(t, &issues_model.TrackedTime{ID: 3}) + req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/issues/%d/times/%d", user2.Name, issue2.Repo.Name, issue2.Index, time3.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + // Delete non existing time + MakeRequest(t, req, http.StatusNotFound) + + // Reset time of user 2 on issue 2 + trackedSeconds, err := issues_model.GetTrackedSeconds(db.DefaultContext, issues_model.FindTrackedTimesOptions{IssueID: 2, UserID: 2}) + require.NoError(t, err) + assert.Equal(t, int64(3661), trackedSeconds) + + req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/issues/%d/times", user2.Name, issue2.Repo.Name, issue2.Index). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + MakeRequest(t, req, http.StatusNotFound) + + trackedSeconds, err = issues_model.GetTrackedSeconds(db.DefaultContext, issues_model.FindTrackedTimesOptions{IssueID: 2, UserID: 2}) + require.NoError(t, err) + assert.Equal(t, int64(0), trackedSeconds) +} + +func TestAPIAddTrackedTimes(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) + require.NoError(t, issue2.LoadRepo(db.DefaultContext)) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + session := loginUser(t, admin.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/times", user2.Name, issue2.Repo.Name, issue2.Index) + + req := NewRequestWithJSON(t, "POST", urlStr, &api.AddTimeOption{ + Time: 33, + User: user2.Name, + Created: time.Unix(947688818, 0), + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiNewTime api.TrackedTime + DecodeJSON(t, resp, &apiNewTime) + + assert.EqualValues(t, 33, apiNewTime.Time) + assert.EqualValues(t, user2.ID, apiNewTime.UserID) + assert.EqualValues(t, 947688818, apiNewTime.Created.Unix()) +} diff --git a/tests/integration/api_keys_test.go b/tests/integration/api_keys_test.go new file mode 100644 index 0000000..86daa8c --- /dev/null +++ b/tests/integration/api_keys_test.go @@ -0,0 +1,213 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestViewDeployKeysNoLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/keys") + MakeRequest(t, req, http.StatusUnauthorized) +} + +func TestCreateDeployKeyNoLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/keys", api.CreateKeyOption{ + Title: "title", + Key: "key", + }) + MakeRequest(t, req, http.StatusUnauthorized) +} + +func TestGetDeployKeyNoLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/keys/1") + MakeRequest(t, req, http.StatusUnauthorized) +} + +func TestDeleteDeployKeyNoLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequest(t, "DELETE", "/api/v1/repos/user2/repo1/keys/1") + MakeRequest(t, req, http.StatusUnauthorized) +} + +func TestCreateReadOnlyDeployKey(t *testing.T) { + defer tests.PrepareTestEnv(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo1"}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/keys", repoOwner.Name, repo.Name) + rawKeyBody := api.CreateKeyOption{ + Title: "read-only", + Key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment\n", + ReadOnly: true, + } + req := NewRequestWithJSON(t, "POST", keysURL, rawKeyBody). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var newDeployKey api.DeployKey + DecodeJSON(t, resp, &newDeployKey) + unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{ + ID: newDeployKey.ID, + Name: rawKeyBody.Title, + Content: rawKeyBody.Key, + Mode: perm.AccessModeRead, + }) + + // Using the ID of a key that does not belong to the repository must fail + { + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/keys/%d", repoOwner.Name, repo.Name, newDeployKey.ID)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + session5 := loginUser(t, "user5") + token5 := getTokenForLoggedInUser(t, session5, auth_model.AccessTokenScopeWriteRepository) + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user5/repo4/keys/%d", newDeployKey.ID)). + AddTokenAuth(token5) + MakeRequest(t, req, http.StatusNotFound) + } +} + +func TestCreateReadWriteDeployKey(t *testing.T) { + defer tests.PrepareTestEnv(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo1"}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/keys", repoOwner.Name, repo.Name) + rawKeyBody := api.CreateKeyOption{ + Title: "read-write", + Key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment\n", + } + req := NewRequestWithJSON(t, "POST", keysURL, rawKeyBody). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var newDeployKey api.DeployKey + DecodeJSON(t, resp, &newDeployKey) + unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{ + ID: newDeployKey.ID, + Name: rawKeyBody.Title, + Content: rawKeyBody.Key, + Mode: perm.AccessModeWrite, + }) +} + +func TestCreateUserKey(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"}) + + session := loginUser(t, "user1") + token := url.QueryEscape(getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)) + keyType := "ssh-rsa" + keyContent := "AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM=" + rawKeyBody := api.CreateKeyOption{ + Title: "test-key", + Key: keyType + " " + keyContent, + } + req := NewRequestWithJSON(t, "POST", "/api/v1/user/keys", rawKeyBody). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var newPublicKey api.PublicKey + DecodeJSON(t, resp, &newPublicKey) + fingerprint, err := asymkey_model.CalcFingerprint(rawKeyBody.Key) + require.NoError(t, err) + unittest.AssertExistsAndLoadBean(t, &asymkey_model.PublicKey{ + ID: newPublicKey.ID, + OwnerID: user.ID, + Name: rawKeyBody.Title, + Fingerprint: fingerprint, + Mode: perm.AccessModeWrite, + }) + + // Search by fingerprint + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/keys?fingerprint=%s", newPublicKey.Fingerprint)). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + var fingerprintPublicKeys []api.PublicKey + DecodeJSON(t, resp, &fingerprintPublicKeys) + assert.Equal(t, newPublicKey.Fingerprint, fingerprintPublicKeys[0].Fingerprint) + assert.Equal(t, newPublicKey.ID, fingerprintPublicKeys[0].ID) + assert.Equal(t, user.ID, fingerprintPublicKeys[0].Owner.ID) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/keys?fingerprint=%s", user.Name, newPublicKey.Fingerprint)). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &fingerprintPublicKeys) + assert.Equal(t, newPublicKey.Fingerprint, fingerprintPublicKeys[0].Fingerprint) + assert.Equal(t, newPublicKey.ID, fingerprintPublicKeys[0].ID) + assert.Equal(t, user.ID, fingerprintPublicKeys[0].Owner.ID) + + // Fail search by fingerprint + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/keys?fingerprint=%sA", newPublicKey.Fingerprint)). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &fingerprintPublicKeys) + assert.Empty(t, fingerprintPublicKeys) + + // Fail searching for wrong users key + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/keys?fingerprint=%s", "user2", newPublicKey.Fingerprint)). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &fingerprintPublicKeys) + assert.Empty(t, fingerprintPublicKeys) + + // Now login as user 2 + session2 := loginUser(t, "user2") + token2 := getTokenForLoggedInUser(t, session2, auth_model.AccessTokenScopeWriteUser) + + // Should find key even though not ours, but we shouldn't know whose it is + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/keys?fingerprint=%s", newPublicKey.Fingerprint)). + AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &fingerprintPublicKeys) + assert.Equal(t, newPublicKey.Fingerprint, fingerprintPublicKeys[0].Fingerprint) + assert.Equal(t, newPublicKey.ID, fingerprintPublicKeys[0].ID) + assert.Nil(t, fingerprintPublicKeys[0].Owner) + + // Should find key even though not ours, but we shouldn't know whose it is + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/keys?fingerprint=%s", user.Name, newPublicKey.Fingerprint)). + AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &fingerprintPublicKeys) + assert.Equal(t, newPublicKey.Fingerprint, fingerprintPublicKeys[0].Fingerprint) + assert.Equal(t, newPublicKey.ID, fingerprintPublicKeys[0].ID) + assert.Nil(t, fingerprintPublicKeys[0].Owner) + + // Fail when searching for key if it is not ours + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/keys?fingerprint=%s", "user2", newPublicKey.Fingerprint)). + AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &fingerprintPublicKeys) + assert.Empty(t, fingerprintPublicKeys) +} diff --git a/tests/integration/api_label_templates_test.go b/tests/integration/api_label_templates_test.go new file mode 100644 index 0000000..3039f8c --- /dev/null +++ b/tests/integration/api_label_templates_test.go @@ -0,0 +1,62 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "testing" + + repo_module "code.gitea.io/gitea/modules/repository" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIListLabelTemplates(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/api/v1/label/templates") + resp := MakeRequest(t, req, http.StatusOK) + + var templateList []string + DecodeJSON(t, resp, &templateList) + + for i := range repo_module.LabelTemplateFiles { + assert.Equal(t, repo_module.LabelTemplateFiles[i].DisplayName, templateList[i]) + } +} + +func TestAPIGetLabelTemplateInfo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // If Gitea has for some reason no Label templates, we need to skip this test + if len(repo_module.LabelTemplateFiles) == 0 { + return + } + + // Use the first template for the test + templateName := repo_module.LabelTemplateFiles[0].DisplayName + + urlStr := fmt.Sprintf("/api/v1/label/templates/%s", url.PathEscape(templateName)) + req := NewRequest(t, "GET", urlStr) + resp := MakeRequest(t, req, http.StatusOK) + + var templateInfo []api.LabelTemplate + DecodeJSON(t, resp, &templateInfo) + + labels, err := repo_module.LoadTemplateLabelsByDisplayName(templateName) + require.NoError(t, err) + + for i := range labels { + assert.Equal(t, strings.TrimLeft(labels[i].Color, "#"), templateInfo[i].Color) + assert.Equal(t, labels[i].Description, templateInfo[i].Description) + assert.Equal(t, labels[i].Exclusive, templateInfo[i].Exclusive) + assert.Equal(t, labels[i].Name, templateInfo[i].Name) + } +} diff --git a/tests/integration/api_license_templates_test.go b/tests/integration/api_license_templates_test.go new file mode 100644 index 0000000..e12aab7 --- /dev/null +++ b/tests/integration/api_license_templates_test.go @@ -0,0 +1,55 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + "code.gitea.io/gitea/modules/options" + repo_module "code.gitea.io/gitea/modules/repository" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIListLicenseTemplates(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/api/v1/licenses") + resp := MakeRequest(t, req, http.StatusOK) + + // This tests if the API returns a list of strings + var licenseList []api.LicensesTemplateListEntry + DecodeJSON(t, resp, &licenseList) +} + +func TestAPIGetLicenseTemplateInfo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // If Gitea has for some reason no License templates, we need to skip this test + if len(repo_module.Licenses) == 0 { + return + } + + // Use the first template for the test + licenseName := repo_module.Licenses[0] + + urlStr := fmt.Sprintf("/api/v1/licenses/%s", url.PathEscape(licenseName)) + req := NewRequest(t, "GET", urlStr) + resp := MakeRequest(t, req, http.StatusOK) + + var licenseInfo api.LicenseTemplateInfo + DecodeJSON(t, resp, &licenseInfo) + + // We get the text of the template here + text, _ := options.License(licenseName) + + assert.Equal(t, licenseInfo.Key, licenseName) + assert.Equal(t, licenseInfo.Name, licenseName) + assert.Equal(t, licenseInfo.Body, string(text)) +} diff --git a/tests/integration/api_nodeinfo_test.go b/tests/integration/api_nodeinfo_test.go new file mode 100644 index 0000000..33d06ed --- /dev/null +++ b/tests/integration/api_nodeinfo_test.go @@ -0,0 +1,39 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "testing" + + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers" + + "github.com/stretchr/testify/assert" +) + +func TestNodeinfo(t *testing.T) { + setting.Federation.Enabled = true + testWebRoutes = routers.NormalRoutes() + defer func() { + setting.Federation.Enabled = false + testWebRoutes = routers.NormalRoutes() + }() + + onGiteaRun(t, func(*testing.T, *url.URL) { + req := NewRequest(t, "GET", "/api/v1/nodeinfo") + resp := MakeRequest(t, req, http.StatusOK) + VerifyJSONSchema(t, resp, "nodeinfo_2.1.json") + + var nodeinfo api.NodeInfo + DecodeJSON(t, resp, &nodeinfo) + assert.True(t, nodeinfo.OpenRegistrations) + assert.Equal(t, "forgejo", nodeinfo.Software.Name) + assert.Equal(t, 29, nodeinfo.Usage.Users.Total) + assert.Equal(t, 22, nodeinfo.Usage.LocalPosts) + assert.Equal(t, 4, nodeinfo.Usage.LocalComments) + }) +} diff --git a/tests/integration/api_notification_test.go b/tests/integration/api_notification_test.go new file mode 100644 index 0000000..ad233d9 --- /dev/null +++ b/tests/integration/api_notification_test.go @@ -0,0 +1,216 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + activities_model "code.gitea.io/gitea/models/activities" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPINotification(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + thread5 := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 5}) + require.NoError(t, thread5.LoadAttributes(db.DefaultContext)) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteNotification, auth_model.AccessTokenScopeWriteRepository) + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/notifications"), http.StatusUnauthorized) + + // -- GET /notifications -- + // test filter + since := "2000-01-01T00%3A50%3A01%2B00%3A00" // 946687801 + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications?since=%s", since)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiNL []api.NotificationThread + DecodeJSON(t, resp, &apiNL) + + assert.Len(t, apiNL, 1) + assert.EqualValues(t, 5, apiNL[0].ID) + + // test filter + before := "2000-01-01T01%3A06%3A59%2B00%3A00" // 946688819 + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications?all=%s&before=%s", "true", before)). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiNL) + + assert.Len(t, apiNL, 3) + assert.EqualValues(t, 4, apiNL[0].ID) + assert.True(t, apiNL[0].Unread) + assert.False(t, apiNL[0].Pinned) + assert.EqualValues(t, 3, apiNL[1].ID) + assert.False(t, apiNL[1].Unread) + assert.True(t, apiNL[1].Pinned) + assert.EqualValues(t, 2, apiNL[2].ID) + assert.False(t, apiNL[2].Unread) + assert.False(t, apiNL[2].Pinned) + + // -- GET /repos/{owner}/{repo}/notifications -- + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/notifications?status-types=unread", user2.Name, repo1.Name)). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiNL) + + assert.Len(t, apiNL, 1) + assert.EqualValues(t, 4, apiNL[0].ID) + + // -- GET /repos/{owner}/{repo}/notifications -- multiple status-types + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/notifications?status-types=unread&status-types=pinned", user2.Name, repo1.Name)). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiNL) + + assert.Len(t, apiNL, 2) + assert.EqualValues(t, 4, apiNL[0].ID) + assert.True(t, apiNL[0].Unread) + assert.False(t, apiNL[0].Pinned) + assert.EqualValues(t, 3, apiNL[1].ID) + assert.False(t, apiNL[1].Unread) + assert.True(t, apiNL[1].Pinned) + + MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications/threads/%d", 1)), http.StatusUnauthorized) + + // -- GET /notifications/threads/{id} -- + // get forbidden + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications/threads/%d", 1)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + // get own + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications/threads/%d", thread5.ID)). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var apiN api.NotificationThread + DecodeJSON(t, resp, &apiN) + + assert.EqualValues(t, 5, apiN.ID) + assert.False(t, apiN.Pinned) + assert.True(t, apiN.Unread) + assert.EqualValues(t, "issue4", apiN.Subject.Title) + assert.EqualValues(t, "Issue", apiN.Subject.Type) + assert.EqualValues(t, thread5.Issue.APIURL(db.DefaultContext), apiN.Subject.URL) + assert.EqualValues(t, thread5.Repository.HTMLURL(), apiN.Repository.HTMLURL) + + MakeRequest(t, NewRequest(t, "GET", "/api/v1/notifications/new"), http.StatusUnauthorized) + + newStruct := struct { + New int64 `json:"new"` + }{} + + // -- check notifications -- + req = NewRequest(t, "GET", "/api/v1/notifications/new"). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &newStruct) + assert.Positive(t, newStruct.New) + + // -- mark notifications as read -- + req = NewRequest(t, "GET", "/api/v1/notifications?status-types=unread"). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiNL) + assert.Len(t, apiNL, 2) + + lastReadAt := "2000-01-01T00%3A50%3A01%2B00%3A00" // 946687801 <- only Notification 4 is in this filter ... + req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/notifications?last_read_at=%s", user2.Name, repo1.Name, lastReadAt)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusResetContent) + + req = NewRequest(t, "GET", "/api/v1/notifications?status-types=unread"). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiNL) + assert.Len(t, apiNL, 1) + + // -- PATCH /notifications/threads/{id} -- + req = NewRequest(t, "PATCH", fmt.Sprintf("/api/v1/notifications/threads/%d", thread5.ID)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusResetContent) + + assert.Equal(t, activities_model.NotificationStatusUnread, thread5.Status) + thread5 = unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 5}) + assert.Equal(t, activities_model.NotificationStatusRead, thread5.Status) + + // -- check notifications -- + req = NewRequest(t, "GET", "/api/v1/notifications/new"). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &newStruct) + assert.Equal(t, int64(0), newStruct.New) +} + +func TestAPINotificationPUT(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + thread5 := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 5}) + require.NoError(t, thread5.LoadAttributes(db.DefaultContext)) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteNotification) + + // Check notifications are as expected + req := NewRequest(t, "GET", "/api/v1/notifications?all=true"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiNL []api.NotificationThread + DecodeJSON(t, resp, &apiNL) + + assert.Len(t, apiNL, 4) + assert.EqualValues(t, 5, apiNL[0].ID) + assert.True(t, apiNL[0].Unread) + assert.False(t, apiNL[0].Pinned) + assert.EqualValues(t, 4, apiNL[1].ID) + assert.True(t, apiNL[1].Unread) + assert.False(t, apiNL[1].Pinned) + assert.EqualValues(t, 3, apiNL[2].ID) + assert.False(t, apiNL[2].Unread) + assert.True(t, apiNL[2].Pinned) + assert.EqualValues(t, 2, apiNL[3].ID) + assert.False(t, apiNL[3].Unread) + assert.False(t, apiNL[3].Pinned) + + // + // Notification ID 2 is the only one with status-type read & pinned + // change it to unread. + // + req = NewRequest(t, "PUT", "/api/v1/notifications?status-types=read&status-type=pinned&to-status=unread"). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusResetContent) + DecodeJSON(t, resp, &apiNL) + assert.Len(t, apiNL, 1) + assert.EqualValues(t, 2, apiNL[0].ID) + assert.True(t, apiNL[0].Unread) + assert.False(t, apiNL[0].Pinned) + + // + // Now nofication ID 2 is the first in the list and is unread. + // + req = NewRequest(t, "GET", "/api/v1/notifications?all=true"). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiNL) + + assert.Len(t, apiNL, 4) + assert.EqualValues(t, 2, apiNL[0].ID) + assert.True(t, apiNL[0].Unread) + assert.False(t, apiNL[0].Pinned) +} diff --git a/tests/integration/api_oauth2_apps_test.go b/tests/integration/api_oauth2_apps_test.go new file mode 100644 index 0000000..85c7184 --- /dev/null +++ b/tests/integration/api_oauth2_apps_test.go @@ -0,0 +1,175 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestOAuth2Application(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testAPICreateOAuth2Application(t) + testAPIListOAuth2Applications(t) + testAPIGetOAuth2Application(t) + testAPIUpdateOAuth2Application(t) + testAPIDeleteOAuth2Application(t) +} + +func testAPICreateOAuth2Application(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + appBody := api.CreateOAuth2ApplicationOptions{ + Name: "test-app-1", + RedirectURIs: []string{ + "http://www.google.com", + }, + ConfidentialClient: true, + } + + req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusCreated) + + var createdApp *api.OAuth2Application + DecodeJSON(t, resp, &createdApp) + + assert.EqualValues(t, appBody.Name, createdApp.Name) + assert.Len(t, createdApp.ClientSecret, 56) + assert.Len(t, createdApp.ClientID, 36) + assert.True(t, createdApp.ConfidentialClient) + assert.NotEmpty(t, createdApp.Created) + assert.EqualValues(t, appBody.RedirectURIs[0], createdApp.RedirectURIs[0]) + unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{UID: user.ID, Name: createdApp.Name}) +} + +func testAPIListOAuth2Applications(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser) + + existApp := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{ + UID: user.ID, + Name: "test-app-1", + RedirectURIs: []string{ + "http://www.google.com", + }, + ConfidentialClient: true, + }) + + req := NewRequest(t, "GET", "/api/v1/user/applications/oauth2"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var appList api.OAuth2ApplicationList + DecodeJSON(t, resp, &appList) + expectedApp := appList[0] + + assert.EqualValues(t, expectedApp.Name, existApp.Name) + assert.EqualValues(t, expectedApp.ClientID, existApp.ClientID) + assert.Equal(t, expectedApp.ConfidentialClient, existApp.ConfidentialClient) + assert.Len(t, expectedApp.ClientID, 36) + assert.Empty(t, expectedApp.ClientSecret) + assert.EqualValues(t, expectedApp.RedirectURIs[0], existApp.RedirectURIs[0]) + unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{ID: expectedApp.ID, Name: expectedApp.Name}) +} + +func testAPIDeleteOAuth2Application(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + oldApp := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{ + UID: user.ID, + Name: "test-app-1", + }) + + urlStr := fmt.Sprintf("/api/v1/user/applications/oauth2/%d", oldApp.ID) + req := NewRequest(t, "DELETE", urlStr). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertNotExistsBean(t, &auth_model.OAuth2Application{UID: oldApp.UID, Name: oldApp.Name}) + + // Delete again will return not found + req = NewRequest(t, "DELETE", urlStr). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func testAPIGetOAuth2Application(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser) + + existApp := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{ + UID: user.ID, + Name: "test-app-1", + RedirectURIs: []string{ + "http://www.google.com", + }, + ConfidentialClient: true, + }) + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/applications/oauth2/%d", existApp.ID)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var app api.OAuth2Application + DecodeJSON(t, resp, &app) + expectedApp := app + + assert.EqualValues(t, expectedApp.Name, existApp.Name) + assert.EqualValues(t, expectedApp.ClientID, existApp.ClientID) + assert.Equal(t, expectedApp.ConfidentialClient, existApp.ConfidentialClient) + assert.Len(t, expectedApp.ClientID, 36) + assert.Empty(t, expectedApp.ClientSecret) + assert.Len(t, expectedApp.RedirectURIs, 1) + assert.EqualValues(t, expectedApp.RedirectURIs[0], existApp.RedirectURIs[0]) + unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{ID: expectedApp.ID, Name: expectedApp.Name}) +} + +func testAPIUpdateOAuth2Application(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + existApp := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{ + UID: user.ID, + Name: "test-app-1", + RedirectURIs: []string{ + "http://www.google.com", + }, + }) + + appBody := api.CreateOAuth2ApplicationOptions{ + Name: "test-app-1", + RedirectURIs: []string{ + "http://www.google.com/", + "http://www.github.com/", + }, + ConfidentialClient: true, + } + + urlStr := fmt.Sprintf("/api/v1/user/applications/oauth2/%d", existApp.ID) + req := NewRequestWithJSON(t, "PATCH", urlStr, &appBody). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var app api.OAuth2Application + DecodeJSON(t, resp, &app) + expectedApp := app + + assert.Len(t, expectedApp.RedirectURIs, 2) + assert.EqualValues(t, expectedApp.RedirectURIs[0], appBody.RedirectURIs[0]) + assert.EqualValues(t, expectedApp.RedirectURIs[1], appBody.RedirectURIs[1]) + assert.Equal(t, expectedApp.ConfidentialClient, appBody.ConfidentialClient) + unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{ID: expectedApp.ID, Name: expectedApp.Name}) +} diff --git a/tests/integration/api_org_avatar_test.go b/tests/integration/api_org_avatar_test.go new file mode 100644 index 0000000..bbe116c --- /dev/null +++ b/tests/integration/api_org_avatar_test.go @@ -0,0 +1,77 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "net/http" + "os" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIUpdateOrgAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization) + + // Test what happens if you use a valid image + avatar, err := os.ReadFile("tests/integration/avatar.png") + require.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open avatar.png") + } + + opts := api.UpdateUserAvatarOption{ + Image: base64.StdEncoding.EncodeToString(avatar), + } + + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/avatar", &opts). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // Test what happens if you don't have a valid Base64 string + opts = api.UpdateUserAvatarOption{ + Image: "Invalid", + } + + req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/avatar", &opts). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusBadRequest) + + // Test what happens if you use a file that is not an image + text, err := os.ReadFile("tests/integration/README.md") + require.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open README.md") + } + + opts = api.UpdateUserAvatarOption{ + Image: base64.StdEncoding.EncodeToString(text), + } + + req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/avatar", &opts). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusInternalServerError) +} + +func TestAPIDeleteOrgAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization) + + req := NewRequest(t, "DELETE", "/api/v1/orgs/org3/avatar"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) +} diff --git a/tests/integration/api_org_test.go b/tests/integration/api_org_test.go new file mode 100644 index 0000000..70d3a44 --- /dev/null +++ b/tests/integration/api_org_test.go @@ -0,0 +1,228 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + org_model "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIOrgCreate(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteOrganization) + + org := api.CreateOrgOption{ + UserName: "user1_org", + FullName: "User1's organization", + Description: "This organization created by user1", + Website: "https://try.gitea.io", + Location: "Shanghai", + Visibility: "limited", + } + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &org). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var apiOrg api.Organization + DecodeJSON(t, resp, &apiOrg) + + assert.Equal(t, org.UserName, apiOrg.Name) + assert.Equal(t, org.FullName, apiOrg.FullName) + assert.Equal(t, org.Description, apiOrg.Description) + assert.Equal(t, org.Website, apiOrg.Website) + assert.Equal(t, org.Location, apiOrg.Location) + assert.Equal(t, org.Visibility, apiOrg.Visibility) + + unittest.AssertExistsAndLoadBean(t, &user_model.User{ + Name: org.UserName, + LowerName: strings.ToLower(org.UserName), + FullName: org.FullName, + }) + + // Check owner team permission + ownerTeam, _ := org_model.GetOwnerTeam(db.DefaultContext, apiOrg.ID) + + for _, ut := range unit_model.AllRepoUnitTypes { + up := perm.AccessModeOwner + if ut == unit_model.TypeExternalTracker || ut == unit_model.TypeExternalWiki { + up = perm.AccessModeRead + } + unittest.AssertExistsAndLoadBean(t, &org_model.TeamUnit{ + OrgID: apiOrg.ID, + TeamID: ownerTeam.ID, + Type: ut, + AccessMode: up, + }) + } + + req = NewRequestf(t, "GET", "/api/v1/orgs/%s", org.UserName). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiOrg) + assert.EqualValues(t, org.UserName, apiOrg.Name) + + req = NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", org.UserName). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + var repos []*api.Repository + DecodeJSON(t, resp, &repos) + for _, repo := range repos { + assert.False(t, repo.Private) + } + + req = NewRequestf(t, "GET", "/api/v1/orgs/%s/members", org.UserName). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + // user1 on this org is public + var users []*api.User + DecodeJSON(t, resp, &users) + assert.Len(t, users, 1) + assert.EqualValues(t, "user1", users[0].UserName) + }) +} + +func TestAPIOrgEdit(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + session := loginUser(t, "user1") + + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization) + org := api.EditOrgOption{ + FullName: "Org3 organization new full name", + Description: "A new description", + Website: "https://try.gitea.io/new", + Location: "Beijing", + Visibility: "private", + } + req := NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3", &org). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var apiOrg api.Organization + DecodeJSON(t, resp, &apiOrg) + + assert.Equal(t, "org3", apiOrg.Name) + assert.Equal(t, org.FullName, apiOrg.FullName) + assert.Equal(t, org.Description, apiOrg.Description) + assert.Equal(t, org.Website, apiOrg.Website) + assert.Equal(t, org.Location, apiOrg.Location) + assert.Equal(t, org.Visibility, apiOrg.Visibility) + }) +} + +func TestAPIOrgEditBadVisibility(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + session := loginUser(t, "user1") + + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization) + org := api.EditOrgOption{ + FullName: "Org3 organization new full name", + Description: "A new description", + Website: "https://try.gitea.io/new", + Location: "Beijing", + Visibility: "badvisibility", + } + req := NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3", &org). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + }) +} + +func TestAPIOrgDeny(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + setting.Service.RequireSignInView = true + defer func() { + setting.Service.RequireSignInView = false + }() + + orgName := "user1_org" + req := NewRequestf(t, "GET", "/api/v1/orgs/%s", orgName) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", orgName) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequestf(t, "GET", "/api/v1/orgs/%s/members", orgName) + MakeRequest(t, req, http.StatusNotFound) + }) +} + +func TestAPIGetAll(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadOrganization) + + // accessing with a token will return all orgs + req := NewRequest(t, "GET", "/api/v1/orgs"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiOrgList []*api.Organization + + DecodeJSON(t, resp, &apiOrgList) + assert.Len(t, apiOrgList, 12) + assert.Equal(t, "Limited Org 36", apiOrgList[1].FullName) + assert.Equal(t, "limited", apiOrgList[1].Visibility) + + // accessing without a token will return only public orgs + req = NewRequest(t, "GET", "/api/v1/orgs") + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &apiOrgList) + assert.Len(t, apiOrgList, 8) + assert.Equal(t, "org 17", apiOrgList[0].FullName) + assert.Equal(t, "public", apiOrgList[0].Visibility) +} + +func TestAPIOrgSearchEmptyTeam(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteOrganization) + orgName := "org_with_empty_team" + + // create org + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{ + UserName: orgName, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // create team with no member + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", orgName), &api.CreateTeamOption{ + Name: "Empty", + IncludesAllRepositories: true, + Permission: "read", + Units: []string{"repo.code", "repo.issues", "repo.ext_issues", "repo.wiki", "repo.pulls"}, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // case-insensitive search for teams that have no members + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/teams/search?q=%s", orgName, "empty")). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + data := struct { + Ok bool + Data []*api.Team + }{} + DecodeJSON(t, resp, &data) + assert.True(t, data.Ok) + if assert.Len(t, data.Data, 1) { + assert.EqualValues(t, "Empty", data.Data[0].Name) + } + }) +} diff --git a/tests/integration/api_packages_alpine_test.go b/tests/integration/api_packages_alpine_test.go new file mode 100644 index 0000000..2264625 --- /dev/null +++ b/tests/integration/api_packages_alpine_test.go @@ -0,0 +1,502 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/tar" + "bufio" + "bytes" + "compress/gzip" + "encoding/base64" + "fmt" + "io" + "net/http" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + alpine_module "code.gitea.io/gitea/modules/packages/alpine" + alpine_service "code.gitea.io/gitea/services/packages/alpine" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageAlpine(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + packageName := "gitea-test" + packageVersion := "1.4.1-r3" + + base64AlpinePackageContent := `H4sIAAAAAAACA9ML9nT30wsKdtTLzjNJzjYuckjPLElN1DUzMUxMNTa11CsqTtQrKE1ioAAYAIGZ +iQmYBgJ02hDENjQxMTAzMzQ1MTVjMDA0MTQ1ZlAwYKADKC0uSSxSUGAYoWDm4sZZtypv75+q2fVT +POD1bKkFB22ms+g1z+H4dk7AhC3HwUSj9EbT0Rk3Dn55dHxy/K7Q+Nl/i+L7Z036ypcRvvpZuMiN +s7wbZL/klqRGGshv9Gi0qHTgTZfw3HytnJdx9c3NTRp/PHn+Z50uq2pjkilzjtpfd+uzQMw1M7cY +i9RXJasnT2M+vDXCesLK7MilJt8sGplj4xUlLMUun9SzY+phFpxWxRXa06AseV9WvzH3jtGGoL5A +vQkea+VKPj5R+Cb461tIk97qpa9nJYsJujTNl2B/J1P52H/D2rPr/j19uU8p7cMSq5tmXk51ReXl +F/Yddr9XsMpEwFKlXSPo3QSGwnCOG8y2uadjm6ui998WYXNYubjg78N3a7bnXjhrl5fB8voI++LI +1FP5W44e2xf4Ou2wrtyic1Onz7MzMV5ksuno2V/LVG4eN/15X/n2/2vJ2VV+T68aT327dOrhd6e6 +q5Y0V82Y83tdqkFa8TW2BvGCZ0ds/iibHVpzKuPcuSULO63/bNmfrnhjWqXzhMSXTb5Cv4vPaxSL +8LFMdqmxbN7+Y+Yi0ZyZhz4UxexLuHHFd1VFvk+kwvniq3P+f9rh52InWnL8Lpvedcecoh1GFSc5 +xZ9VBGex2V269HZfwxSVCvP35wQfi2xKX+lYMXtF48n1R65O2PLWpm69RdESMa79dlrTGazsZacu +MbMLeSSScPORZde76/MBV6SFJAAEAAAfiwgAAAAAAAID7VRLaxsxEN6zfoUgZ++OVq+1aUIhUDeY +pKa49FhmJdkW3ofRysXpr69220t9SCk0gZJ+IGaY56eBmbxY4/m9Q+vCUOTr1fLu4d2H7O8CEpQQ +k0y4lAClypgQoBSTQqoMGBMgMnrOXgCnIWJIVLLXCcaoib5110CSij/V7D9eCZ5p5f9o/5VkF/tf +MqUzCi+5/6Hv41Nxv/Nffu4fwRVdus4FjM7S+pFiffKNpTxnkMMsALmin5PnHgMtS8rkgvGFBPpp +c0tLKDk5HnYdto5e052PDmfRDXE0fnUh2VgucjYLU5h1g0mm5RhGNymMrtEccOfIKTTJsY/xOCyK +YqqT+74gExWbmI2VlJ6LeQUcyPFH2lh/9SBuV/wjfXPohDnw8HZKviGD/zYmCZgrgsHsk36u1Bcl +SB/8zne/0jV92/qYbKRF38X0niiemN2QxhvXDWOL+7tNGhGeYt+m22mwaR6pddGZNM8FSeRxj8PY +X7PaqdqAVlqWXHKnmQGmK43VlqNlILRilbBSMI2jV5Vbu5XGSVsDyGc7yd8B/gK2qgAIAAAfiwgA +AAAAAAID7dNNSgMxGAbg7MSCOxcu5wJOv0x+OlkU7K5QoYXqVsxMMihlKMwP1Fu48QQewCN4DfEQ +egUz4sYuFKEtFN9n870hWSSQN+7P7GrsrfNV3Y9dW5Z3bNMo0FJ+zmB9EhcJ41KS1lxJpRnxbsWi +FduBtm5sFa7C/ifOo7y5Lf2QeiHar6jTaDSbnF5Mp+fzOL/x+aJuy3g+HvGhs8JY4b3yOpMZOZEo +lRW+MEoTTw3ZwqU0INNjsAe2VPk/9b/L3/s/kIKzqOtk+IbJGTtmr+bx7WoxOUoun98frk/un14O +Djfa/2q5bH4699v++uMAAAAAAAAAAAAAAAAAAAAAAHbgA/eXQh8AKAAA` + content, err := base64.StdEncoding.DecodeString(base64AlpinePackageContent) + require.NoError(t, err) + + branches := []string{"v3.16", "v3.17", "v3.18"} + repositories := []string{"main", "testing"} + + rootURL := fmt.Sprintf("/api/packages/%s/alpine", user.Name) + + t.Run("RepositoryKey", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", rootURL+"/key") + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "application/x-pem-file", resp.Header().Get("Content-Type")) + assert.Contains(t, resp.Body.String(), "-----BEGIN PUBLIC KEY-----") + }) + + for _, branch := range branches { + for _, repository := range repositories { + t.Run(fmt.Sprintf("[Branch:%s,Repository:%s]", branch, repository), func(t *testing.T) { + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadURL := fmt.Sprintf("%s/%s/%s", rootURL, branch, repository) + + req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeAlpine) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.Nil(t, pd.SemVer) + assert.IsType(t, &alpine_module.VersionMetadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.NotEmpty(t, pfs) + assert.Condition(t, func() bool { + seen := false + expectedFilename := fmt.Sprintf("%s-%s.apk", packageName, packageVersion) + expectedCompositeKey := fmt.Sprintf("%s|%s|x86_64", branch, repository) + for _, pf := range pfs { + if pf.Name == expectedFilename && pf.CompositeKey == expectedCompositeKey { + if seen { + return false + } + seen = true + + assert.True(t, pf.IsLead) + + pfps, err := packages.GetProperties(db.DefaultContext, packages.PropertyTypeFile, pf.ID) + require.NoError(t, err) + + for _, pfp := range pfps { + switch pfp.Name { + case alpine_module.PropertyBranch: + assert.Equal(t, branch, pfp.Value) + case alpine_module.PropertyRepository: + assert.Equal(t, repository, pfp.Value) + case alpine_module.PropertyArchitecture: + assert.Equal(t, "x86_64", pfp.Value) + } + } + } + } + return seen + }) + }) + + readIndexContent := func(r io.Reader) (string, error) { + br := bufio.NewReader(r) + + gzr, err := gzip.NewReader(br) + if err != nil { + return "", err + } + + for { + gzr.Multistream(false) + + tr := tar.NewReader(gzr) + for { + hd, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + + if hd.Name == alpine_service.IndexFilename { + buf, err := io.ReadAll(tr) + if err != nil { + return "", err + } + + return string(buf), nil + } + } + + err = gzr.Reset(br) + if err == io.EOF { + break + } + if err != nil { + return "", err + } + } + + return "", io.EOF + } + + t.Run("Index", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + url := fmt.Sprintf("%s/%s/%s/x86_64/APKINDEX.tar.gz", rootURL, branch, repository) + + req := NewRequest(t, "GET", url) + resp := MakeRequest(t, req, http.StatusOK) + + content, err := readIndexContent(resp.Body) + require.NoError(t, err) + + assert.Contains(t, content, "C:Q1/se1PjO94hYXbfpNR1/61hVORIc=\n") + assert.Contains(t, content, "P:"+packageName+"\n") + assert.Contains(t, content, "V:"+packageVersion+"\n") + assert.Contains(t, content, "A:x86_64\n") + assert.NotContains(t, content, "A:noarch\n") + assert.Contains(t, content, "T:Gitea Test Package\n") + assert.Contains(t, content, "U:https://gitea.io/\n") + assert.Contains(t, content, "L:MIT\n") + assert.Contains(t, content, "S:1353\n") + assert.Contains(t, content, "I:4096\n") + assert.Contains(t, content, "o:gitea-test\n") + assert.Contains(t, content, "m:KN4CK3R <kn4ck3r@gitea.io>\n") + assert.Contains(t, content, "t:1679498030\n") + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, packageName, packageVersion)) + MakeRequest(t, req, http.StatusOK) + }) + }) + } + } + + t.Run("Delete", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + for _, branch := range branches { + for _, repository := range repositories { + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, packageName, packageVersion)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, packageName, packageVersion)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + // Deleting the last file of an architecture should remove that index + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/x86_64/APKINDEX.tar.gz", rootURL, branch, repository)) + MakeRequest(t, req, http.StatusNotFound) + } + } + }) +} + +func TestPackageAlpineNoArch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + packageNames := []string{"forgejo-noarch-test", "forgejo-noarch-test-openrc"} + packageVersion := "1.0.0-r0" + + base64AlpinePackageContent := `H4sIAAAAAAACA9ML9nT30wsKdtSryMxLrExJLdIrKk7UKyhNYqAaMAACMxMTMA0E6LQhiG1oYmpm +ZGhqZGBkzmBgaGRsaM6gYMBAB1BaXJJYpKDAMEKBxuPYoD/96z0zNn4N0464vt6n6JW44rN8ppVn +DtjwvbEVF3xzIV5uT1rSlI7S7Qq75j/z29q5ZoeawuaviYKTK/cYCX/Zuzhi1h1Pjk31NWyJfvts +665n++ytWf6aoSylw+xYXv57tTdHPt7duGfS0oS+N8E/XVXnSqueii/8FKF6XURDXyj8gv27ORk8 +v8M8znXGXNze/lzwlKyTuXqc6svbH/7u6pv0uHGrjcEavud5PL8krmQ4bn3zt3Jeh9y6WTJfvcLn +5uy9s9vFyqSHh1dZiCOwqVCjg3nljDWs/06eTfQSuslTeE9pRUP1u6Yq69LDUxvenFmadW5y5cYN +P/+IMJx/pb8hNvDKimVlKT2dLlZNkkq+Z9eytdhlWjakXP/JMe15zOc1s9+4G4RMf33E/kzF55Lz +za7vP9cb8FkL6W3mvfYvzf7LjB1/8pes7NSOzzu6q17GSuZKmuA8fpFnpWuTVjst73gqctl1V6eW +irR9av9Rqcb4Lwyx34xDtWoTTvYvCdfxL3+hyNu2p1550dcEjZrJvKX7d9+wNmpJelfAuvKnzeXe +SvUbyuybQs4eefFb/IVlnFXkjyY7ma6tz3Rlrnw6nl2tXdg9o2wW26GTrm9nLvE0Xrj5XM9MVuFM +rhrGubNe8O4JrW12cTJaaLTreWXyep2Pb4/f49oQkFu67neQ0t4lt2uyXZQ+bn1dEeKy/L3292cA +2zwJWgAEAAAfiwgAAAAAAAID7VVdr9MwDO1zfkWkPa9L2vRjFUMgpA0Egklwn5GbpF1Ym0xpOnb5 +9bjbxMOVACHBldDuqSo7sWufOLIbL7Zweq1BaT8s4u3bzZv36w/R3wVD5EKcJeKhZCzJIy6yPOFZ +wpIiYpynRRbRU/QIGIcAHqlEtwnOqQym1ytGUIWrGj3hRvCPWv5P+p/j86D/WcHyiLLH7H/vXPiV +3+/s/ylmdKOt9hC0ovU9hXo0naJpzJOYzT0jMzoOxra0gb2eSkCP+KMwzlIep0nM0f5xtHSta4rj +g4uKZRUqd59eUbxKQQ771kKv6Yo2zrf6i5tbB17u5kEPYbJiODTymF3S4Y7Sg8St9cWfzhJepNTr +g3dqxFHlLBl9hw67EA5DtVhcA8coyJm9wsNMMQtW5DlLOScHkHtoz5nu7N66r5YM5tvklDBRMjIx +wsWFGnHetMb+hLJ0fW8CGkkPxgZ8z2FfdvoEVnmNWq+NAvqsNeEFOLgsY/zuOemM1HaY8m62744p +Fg/G4HqcuhK67p4qHbTEm6gInvZosBLoKntVXZl8nmqx+lEsPCjsYJioC2A1k1m25KWq67JcJk2u +5FJKIZXgItWsgbqsdZoL1bAmF0wsVV4XDVcNB8ieJv6N4jubJ8CtAAoAAB+LCAAAAAAAAgPt1r9K +w0AcB/CbO3eWWBcX2/ufdChYBCkotFChiyDX5GqrrZGkha5uPoe4+AC+gG/j4OrstTjUgErRRku/ +nyVHEkjg8v3+Uq60zLRhTWSTtDJJE7IC1NFSzo9O9kgp14RJpTlTnHKfUMaoEMSbkhxM0rFJ3KuQ +zcSYF44HI1ujBbc070sCG8JFvrLqZ8wi7iv1ef7d+mP+qRSMeCrP/CdxPP7qvu+ur/H+L0yA7uDq +X/S/lBr9j/6HPPLvQr/SGbB8/zNO0f+57v/CDOjFybm9iM8480Uu/c8Ez+y/UAr//3/Y/zrw6q2j +vZNm87hdDvs2vEwno3K7UWc1Iw1341kw21U26mkeBIFPlW+rmkktopAHTIWmihmyVvn/9dAv0/8i +8//Hqe9OebNMus+Q75Miub8rHmw9vrzu3l53ns1h7enm9AH9/3M72/PtT/uFgg37sVdq2OEw9jpx +MoxKyDAAAAAAAAAAAADA2noDOINxQwAoAAA=` + content, err := base64.StdEncoding.DecodeString(base64AlpinePackageContent) + require.NoError(t, err) + + packageContents := map[string][]byte{} + packageContents["forgejo-noarch-test"] = content + + base64AlpinePackageContent = `H4sIAAAAAAACA9ML9nT30wsKdtSryMxLrExJLdIrKk7UKyhNYqAaMAACMxMTMA0E6LQhiG1oYmpm +ZGhqZGBkzmBgaGRsaM6gYMBAB1BaXJJYpKDAMEJBV8/bw4880tiXWbav8ygSDheyNpq/YubDz3sy +FI+wSHHGpNtx/wpYGTCzVFxz2/pdCvcWzJ3gY2k2L5I7dfvG43G+ja0KkSwPedaI8/05HFGq9ru0 +ye/lIfvchSobf0lGnFr8SWmnXR0DayuTQu70y3wRDR9ltIQ3OE6X2PZs2kv3tKerFs3YkL2XPyPx +h8TGDV8EFtwLXO35KOdkp/yS817if/vC9/J1bfzBXa8Y8mBvzd0dP5p5HkprPls69e0d39anVa9a ++7n2P1Uw0fyoIcP5zn8NS+blmRzXrrxMNpR8Lif37J/GbDWDyocte6f/fjYz62Lw+hPxt7/buhkJ +lJ742LRi+idxvn8B2tGB/Sotkle9Pb1XtJq912V6PHGSmWEie1WIeMvnY6pCPCt366o6uOSv7d4j +0qv2j2vps3tsCw7NnjU/+ixj1aK+GQLWy2+elC1fuL3iQsmatsb6WbGqz2bEvdwzXWhi5lO7C24S +TJt4jjBFff3Y++/P/NvhjakNT294TLnRJZrsHto4cYeSqlPsyhrPz/d0LmmbKeVu6CgMTNpuMl3U +ceaNiqs/xFSevWlUeSl7JT9dTHVi8MqmwPTlXkXF0jGbfioscdJg/cTwa39/jPzxnJ9vw101502Y +XXIpq0jgzsYT20SXnp5l2fZqF/MtG7mCju+uL9nO6Bro7taZnzJlyre/f9pP+Vb058+Sdv3zWHQD +AJIwfO8ABAAAH4sIAAAAAAACA+1V3W/TMBDPs/+Kk/oCD03tfDhtRRFoUgsCsQrYM3KcS2qa2JHj +jG1/Pdd24mGaQEgwCbafFN35fOf7cO4cz7bq6g2qCv0wi7fvNm8/rM+jPwtOkFl2pIS7lPNERiLL +ZSLyhCdFxIVIizyCq+gBMA5BeQolepwQAnQwHa44I1bdstETHgn+Usv/Tv8LLsWd/ueFyCLgD9n/ +3rnwM71f7f+jmMAGLXoVsILyGlQ5mraCNBZJzKeeswmMg7EN1GqPhxLAJT0UxlkQcZrEgvY/jRbW +WAKND5Eteb4k5uLzGdBVZqzfN1Z1CCuonW/wq5tap7zeTQMOYep6tF4flOhU0hExP3klSYWDJtH6 +ZAaTRBQpeOy9q0aaWBTBs3My/3gGxpoAg/amD8NzNvqWzHYh9MNyNrv1GhNhx9QqyvTgqeCFlDwV +gvVK71Vz9H9h99Z9s2wwN0clmc4zdgiXFqe4mfOmMfb+fJh2XUexrIB1ythA3/HY1y1eKVt5JK5D +Uyl40ZjwSjl1WsZk95K1RqMdDn432/eXKTOW/sy2/WJqEp0qdZ/T1Y+iTUCNwXU0wzXZXUOFATXd +65JR0mqnhkMai0xX1Iyasi8xSzGpy1woqoQUUhYokoVKRb6Qc6VLuShzFJmUtcwWRbGY10n69DT8 +X/gOnWH3xAAKAAAfiwgAAAAAAAID7dVNSsNAGAbg2bgwbnuAWDcKNpmZzCTpImAXhYJCC3Uv+Zlo +lCSSHyiKK8G1C8/gGbyLp9ADiBN1UQsqRZNa+j2bGZJAApP3/TR95E4Gwg1Eluui8FENsGQy9rZK +syvG1ESEcZMSTjG1ECbYsjhSJ6gBZV64mfwUtJoIUf0iioWDFbl1P7YIrAgZeb3ud1QRtzj/Ov9y +P5N/wzKQypvMf5amxXfP/XR/ic9/agJESVRowcL7Xy7Q/9D/oJH8v4e+vjEwf/8TbmLo/4bPf2oM +hGl2LE7TI0rkHK69/4lBZ86fVZeg/xfW/6at9kb7ncPh8GCs+SfCP8vLWBsPesTxbMZDEZIuDjzq +W9xysWebmBuBbbgm44R1mWGHFGbIsuX/b0M/R/8Twj7nnxJS9X+VSfkb0j3UQg/9l6fbx93yYuNm +zbm+77fu7Gfo/9/b2tRzL0r09Fwkmd/JykRR/DSO3SRw2nqZZ3p1d/rXaCtKIOTTwfaOeqmsJ0IE +aiIK5QoSDwAAAAAAAAAAAAAAAP/IK49O1e8AKAAA` + content, err = base64.StdEncoding.DecodeString(base64AlpinePackageContent) + require.NoError(t, err) + + packageContents["forgejo-noarch-test-openrc"] = content + + branches := []string{"v3.16", "v3.17", "v3.18"} + repositories := []string{"main", "testing"} + + rootURL := fmt.Sprintf("/api/packages/%s/alpine", user.Name) + + t.Run("RepositoryKey", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", rootURL+"/key") + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "application/x-pem-file", resp.Header().Get("Content-Type")) + assert.Contains(t, resp.Body.String(), "-----BEGIN PUBLIC KEY-----") + }) + + for _, branch := range branches { + for _, repository := range repositories { + t.Run(fmt.Sprintf("[Branch:%s,Repository:%s]", branch, repository), func(t *testing.T) { + for _, pkg := range packageNames { + t.Run(fmt.Sprintf("Upload[Package:%s]", pkg), func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadURL := fmt.Sprintf("%s/%s/%s", rootURL, branch, repository) + + req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(packageContents[pkg])). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageName(db.DefaultContext, user.ID, packages.TypeAlpine, pkg) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.Nil(t, pd.SemVer) + assert.IsType(t, &alpine_module.VersionMetadata{}, pd.Metadata) + assert.Equal(t, pkg, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.NotEmpty(t, pfs) + assert.Condition(t, func() bool { + seen := false + expectedFilename := fmt.Sprintf("%s-%s.apk", pkg, packageVersion) + expectedCompositeKey := fmt.Sprintf("%s|%s|x86_64", branch, repository) + for _, pf := range pfs { + if pf.Name == expectedFilename && pf.CompositeKey == expectedCompositeKey { + if seen { + return false + } + seen = true + + assert.True(t, pf.IsLead) + + pfps, err := packages.GetProperties(db.DefaultContext, packages.PropertyTypeFile, pf.ID) + require.NoError(t, err) + + for _, pfp := range pfps { + switch pfp.Name { + case alpine_module.PropertyBranch: + assert.Equal(t, branch, pfp.Value) + case alpine_module.PropertyRepository: + assert.Equal(t, repository, pfp.Value) + case alpine_module.PropertyArchitecture: + assert.Equal(t, "x86_64", pfp.Value) + } + } + } + } + return seen + }) + }) + } + + t.Run("Index", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + url := fmt.Sprintf("%s/%s/%s/x86_64/APKINDEX.tar.gz", rootURL, branch, repository) + + req := NewRequest(t, "GET", url) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Condition(t, func() bool { + br := bufio.NewReader(resp.Body) + + gzr, err := gzip.NewReader(br) + require.NoError(t, err) + + for { + gzr.Multistream(false) + + tr := tar.NewReader(gzr) + for { + hd, err := tr.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + + if hd.Name == "APKINDEX" { + buf, err := io.ReadAll(tr) + require.NoError(t, err) + + s := string(buf) + + assert.Contains(t, s, "C:Q14rbX8G4tErQO98k5J4uHsNaoiqk=\n") + assert.Contains(t, s, "P:"+packageNames[0]+"\n") + assert.Contains(t, s, "V:"+packageVersion+"\n") + assert.Contains(t, s, "A:x86_64\n") + assert.Contains(t, s, "T:Forgejo #2173 reproduction\n") + assert.Contains(t, s, "U:https://forgejo.org\n") + assert.Contains(t, s, "L:GPLv3\n") + assert.Contains(t, s, "S:1508\n") + assert.Contains(t, s, "I:20480\n") + assert.Contains(t, s, "o:forgejo-noarch-test\n") + assert.Contains(t, s, "m:Alexandre Almeida <git@aoalmeida.com>\n") + assert.Contains(t, s, "t:1707660311\n") + assert.Contains(t, s, "p:cmd:forgejo_2173=1.0.0-r0") + + assert.Contains(t, s, "C:Q1zTXZP03UbSled31mi4MXmsrgNQ4=\n") + assert.Contains(t, s, "P:"+packageNames[1]+"\n") + assert.Contains(t, s, "V:"+packageVersion+"\n") + assert.Contains(t, s, "A:x86_64\n") + assert.Contains(t, s, "T:Forgejo #2173 reproduction (OpenRC init scripts)\n") + assert.Contains(t, s, "U:https://forgejo.org\n") + assert.Contains(t, s, "L:GPLv3\n") + assert.Contains(t, s, "S:1569\n") + assert.Contains(t, s, "I:16384\n") + assert.Contains(t, s, "o:forgejo-noarch-test\n") + assert.Contains(t, s, "m:Alexandre Almeida <git@aoalmeida.com>\n") + assert.Contains(t, s, "t:1707660311\n") + assert.Contains(t, s, "i:openrc forgejo-noarch-test=1.0.0-r0") + + return true + } + } + + err = gzr.Reset(br) + if err == io.EOF { + break + } + require.NoError(t, err) + } + + return false + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, packageNames[0], packageVersion)) + MakeRequest(t, req, http.StatusOK) + }) + }) + } + } + + t.Run("Delete", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + for _, branch := range branches { + for _, repository := range repositories { + for _, pkg := range packageNames { + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, pkg, packageVersion)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, pkg, packageVersion)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + } + // Deleting the last file of an architecture should remove that index + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/x86_64/APKINDEX.tar.gz", rootURL, branch, repository)) + MakeRequest(t, req, http.StatusNotFound) + } + } + }) +} diff --git a/tests/integration/api_packages_arch_test.go b/tests/integration/api_packages_arch_test.go new file mode 100644 index 0000000..8651af4 --- /dev/null +++ b/tests/integration/api_packages_arch_test.go @@ -0,0 +1,433 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/tar" + "bufio" + "bytes" + "compress/gzip" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "testing" + "testing/fstest" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + arch_model "code.gitea.io/gitea/modules/packages/arch" + "code.gitea.io/gitea/tests" + + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/ProtonMail/go-crypto/openpgp/packet" + "github.com/stretchr/testify/require" +) + +func TestPackageArch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + unPack := func(s string) []byte { + data, _ := base64.StdEncoding.DecodeString(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(s), "\n", ""), "\r", "")) + return data + } + rootURL := fmt.Sprintf("/api/packages/%s/arch", user.Name) + + pkgs := map[string][]byte{ + "any": unPack(` +KLUv/QBYXRMABmOHSbCWag6dY6d8VNtVR3rpBnWdBbkDAxM38Dj3XG3FK01TCKlWtMV9QpskYdsm +e6fh5gWqM8edeurYNESoIUz/RmtyQy68HVrBj1p+AIoAYABFSJh4jcDyWNQgHIKIuNgIll64S4oY +FFIUk6vJQBMIIl2iYtIysqKWVYMCYvXDpAKTMzVGwZTUWhbciFCglIMH1QMbEtjHpohSi8XRYwPr +AwACSy/fzxO1FobizlP7sFgHcpx90Pus94Edjcc9GOustbD3PBprLUxH50IGC1sfw31c7LOfT4Qe +nh0KP1uKywwdPrRYmuyIkWBHRlcLfeBIDpKKqw44N0K2nNAfFW5grHRfSShyVgaEIZwIVVmFGL7O +88XDE5whJm4NkwA91dRoPBCcrgqozKSyah1QygsWkCshAaYrvbHCFdUTJCOgBpeUTMuJJ6+SRtcj +wIRua8mGJyg7qWoqJQq9z/4+DU1rHrEO8f6QZ3HUu3IM7GY37u+jeWjUu45637yN+qj338cdi0Uc +y0a9a+e5//1cYnPUu37dxr15khzNQ9/PE80aC/1okjz9mGo3bqP5Ue+scflGshdzx2g28061k2PW +uKwzjmV/XzTzzmKdcfz3eRbJoRPddcaP/n4PSZqQeYa1PDtPQzOHJK0amfjvz0IUV/v38xHJK/rz +JtFpalPD30drDWi7Bl8NB3J/P3csijQyldWZ8gy3TNslLsozMw74DhoAXoAfnE8xydUUHPZ3hML4 +2zVDGiEXSGYRx4BKQDcDJA5S9Ca25FRgPtSWSowZJpJTYAR9WCPHUDgACm6+hBecGDPNClpwHZ2A +EQ== +`), + "x86_64": unPack(` +KLUv/QBYnRMAFmOJS7BUbg7Un8q21hxCopsOMn6UGTzJRbHI753uOeMdxZ+V7ajoETVxl9CSBCR5 +2a3K1vr1gwyp9gCTH422bRNxHEg7Z0z9HV4rH/DGFn8AjABjAFQ2oaUVMRRGViVoqmxAVKuoKQVM +NJRwTDl9NcHCClliWjTpWin6sRUZsXSipWlAipQnleThRgFF5QTAzpth0UPFkhQeJRnYOaqSScEC +djCPDwE8pQTfVXW9F7bmznX3YTNZDeP7IHgxDazNQhp+UDa798KeRgvvvbCamgsYdL461TfvcmlY +djFowWYH5yaH5ztZcemh4omAkm7iQIWvGypNIXJQNgc7DVuHjx06I4MZGTIkeEBIOIL0OxcvnGps +0TwxycqKYESrwwQYEDKI2F0hNXH1/PCQ2BS4Ykki48EAaflAbRHxYrRQbdAZ4oXVAMGCkYOXkBRb +NkwjNCoIF07ByTlyfJhmoHQtCbFYDN+941783KqzusznmPePXJPluS1+cL/74Rd/1UHluW15blFv +ol6e+8XPPZNDPN/Kc9vOdX/xNZrT8twWnH34U9Xkqw76rqqrPjPQl6nJde9i74e/8Mtz6zOjT3R7 +Uve8BrabpT4zanE83158MtVbkxbH84vPNWkGqeu2OF704vfRzAGl6mhRtXPdmOrRzFla+BO+DL34 +uHHN9r74usjkduX5VEhNz9TnxV9trSabvYAwuIZffN0zSeZM3c3GUHX8dG6jeUgHGgBbgB9cUDHJ +1RR09teBwvjbNUMaIRdIZhHHgEpANwMkDpL0JsbkVFA+0JZKjBkmklNgBH1YI8dQOAAKbr6EF5wY +M80KWnAdnYAR +`), + "aarch64": unPack(` +KLUv/QBYdRQAVuSMS7BUbg7Un8q21hxCopsOMn6UGTzJRbHI753uOeMdxZ+V7ajoEbUkUXbXhXW/ +7FanWzv7B/EcMxhodFqyZkUcB9LOGVN/h9MqG7zFFmoAaQB8AEFrvpXntn3V/cXXaE7Lc9uP5uFP +VXPl+ue7qnJ9Zp8vU3PVvYu9HvbAL8+tz4y+0O1J3TPXqbZ5l3+lapk5ee+L577qXvdf+Atn+P69 +4Qz8QhpYw4/xd78Q3/v6Wg28974u1Ojc2ODseAGpHs2crYG4kef84uNGnu198fWQuVq+8ymQmp5p +z4vPbRjOaBC+FxziF1/3TJI5U3ezMlQdPZ3baA7SMhnMunvHvfg5rrO6zOeY94+rJstzW/zgetfD +Lz7XP+W5bXluUW+hXp77xc89kwFRTF1PrKxAFpgXT7ZWhjzYjpRIStGyNCAGBYM6AnGrkKKCAmAH +k3HBI8VyBBYdGdApmoqJYQE62EeIADCkBF1VOW0WYnz/+y6ufTMaDQ2GDDme7Wapz4xa3JpvLz6Z +6q1Ji1vzi79q0vxR+ba4dejF76OZ80nV0aJqX3VjKCsuP1g0EWDSURyw0JVDZWlEzsnmYLdh8wDS +I2dkIEMjxsSOiAlJjH4HIwbTjayZJidXVxKQYH2gICOCBhK7KqMlLZ4gMCU1BapYlsTAXnywepyy +jMBmtEhxyCnCZdUAwYKxAxeRFVk4TCL0aYgWjt3kHTg9SjVStppI2YCSWshUEFGdmJmyCVGpnqIU +KNlA0hEjIOACGSLqYpXAD5SSNVT2MJRJwREAF4FRHPBlCJMSNwFguGAWDJBg+KIArkIJGNtCydUL +TuN1oBh/+zKkEblAsgjGqVgUwKLP+UOMOGCpAhICtg6ncFJH`), + "otherXZ": unPack(` +/Td6WFoAAATm1rRGBMCyBIAYIQEWAAAAAAAAABaHRszgC/8CKl0AFxNGhTWwfXmuDQEJlHgNLrkq +VxpJY6d9iRTt6gB4uCj0481rnYfXaUADHzOFuF3490RPrM6juPXrknqtVyuWJ5efW19BgwctN6xk +UiXiZaXVAWVWJWy2XHJiyYCMWBfIjUfo1ccOgwolwgFHJ64ZJjbayA3k6lYPcImuAqYL5NEVHpwl +Z8CWIjiXXSMQGsB3gxMdq9nySZbHQLK/KCKQ+oseF6kXyIgSEyuG4HhjVBBYIwTvWzI06kjNUXEy +2sw0n50uocLSAwJ/3mdX3n3XF5nmmuQMPtFbdQgQtC2VhyVd3TdIF+pT6zAEzXFJJ3uLkNbKSS88 +ZdBny6X/ftT5lQpNi/Wg0xLEQA4m4fu4fRAR0kOKzHM2svNLbTxa/wOPidqPzR6b/jfKmHkXxBNa +jFafty0a5K2S3F6JpwXZ2fqti/zG9NtMc+bbuXycC327EofXRXNtuOupELDD+ltTOIBF7CcTswyi +MZDP1PBie6GqDV2GuPz+0XXmul/ds+XysG19HIkKbJ+cQKp5o7Y0tI7EHM8GhwMl7MjgpQGj5nuv +0u2hqt4NXPNYqaMm9bFnnIUxEN82HgNWBcXf2baWKOdGzPzCuWg2fAM4zxHnBWcimxLXiJgaI8mU +J/QqTPWE0nJf1PW/J9yFQVR1Xo0TJyiX8/ObwmbqUPpxRGjKlYRBvn0jbTdUAENBSn+QVcASRGFE +SB9OM2B8Bg4jR/oojs8Beoq7zbIblgAAAACfRtXvhmznOgABzgSAGAAAKklb4rHEZ/sCAAAAAARZ +Wg==`), + "otherZST": unPack(` +KLUv/QRYbRMABuOHS9BSNQdQ56F+xNFoV3CijY54JYt3VqV1iUU3xmj00y2pyBOCuokbhDYpvNsj +ZJeCxqH+nQFpMf4Wa92okaZoF4eH6HsXXCBo+qy3Fn4AigBgAEaYrLCQEuAom6YbHyuKZAFYksqi +sSOFiRs0WDmlACk0CnpnaAeKiCS3BlwVkViJEbDS43lFNbLkZEmGhc305Nn4AMLGiUkBDiMTG5Vz +q4ZISjCofEfR1NpXijvP2X95Hu1e+zLalc0+mjeT3Z/FPGvt62WymbX2dXMDIYKDLjjP8n03RrPf +A1vOApwGOh2MgE2LpgZrgXLDF2CUJ15idG2J8GCSgcc2ZVRgA8+RHD0k2VJjg6mRUgGGhBWEyEcz +5EePLhUeWlYhoFCKONxUiBiIUiQeDIqiQwkjLiyqnF5eGs6a2gGRapbU9JRyuXAlPemYajlJojJd +GBBJjo5GxFRkITOAvLhSCr2TDz4uzdU8Yh3i/SHP4qh3vTG2s9198NP8M+pdR73BvIP6qPeDjzsW +gTi+jXrXWOe5P/jZxOeod/287v6JljzNP99RNM0a+/x4ljz3LNV2t5v9qHfW2Pyg24u54zSfObWX +Y9bYrCTHtwdfPPPOYiU5fvB5FssfNN2V5EIPfg9LnM+JhtVEO8+FZw5LXA068YNPhimu9sHPQiWv +qc6fE9BTnxIe/LTKatab+WYu7T74uWNRxJW5W5Ux0bDLuG1ioCwjg4DvGgBcgB8cUDHJ1RQ89neE +wvjbNUMiIZdo5hbHgEpANwMkDnL0Jr7kVFg+0pZKjBkmklNgBH1YI8dQOAAKbr6EF5wYM80KWnAd +nYARrByncQ==`), + "otherGZ": unPack(` +H4sIAAAAAAAAA9PzDQlydWWgKTAwMDAzMVEA0UCAThsYGBuZKRiamBmbm5qZGJqbKBgYGpobGzMo +GNDWWRBQWlySWAR0SlF+fgk+dYTk0T03RIB8NweEwVx71tDviIFA60O75Rtc5s+9YbxteUHzhUWi +HBkWDcbGcUqCukrLGi4Lv8jIqNsbXhueXW8uzTe79Lr9/TVbnl69c3wR652f21+7rnU5kmjTc/38 +8t+zLx/+ePFr6lajpZ2dzCkyB3NPTxdVOfFk2/RXmq+Ktq2dbnY6RcPCMW8Kg9aGszs1f6+YsTlf +x5j5eIpXnXzStAbJvQvPP3su//3lu2/2pj++XO9hbJS+puPmqJKREff4X+RUqdYTbpGTBGYuefH9 +mNbGzKNdiUmS+xgt7J+5iTMObIgOLaAX4O3u6efmT0s7COV/UwNztPxvZGhqOpr/6QGUFdxT81KL +EktSUxSSKhVyE7NTC7LTFcz0DPUMuJQVSosz89IV0oCiIP8rlKUWFWfm5ykY6hmbcgHV5SXmpirY +KpSkFpcYgfhJicUIfkVKYkkikAcUL6ksSLUF0iA1QDOAgkDj9Qx0DUECKanFyVBNCgWJydmJ6alc +pUU5QKGMkpKCYit9/dSKxNyCnFS95Pxcfa6k0sycFKDRIIsMzQ0tTS2NDSxMuKA6QWaH5mXn5Zfn +KQRAhbiKM6tAqg24EouSM4CMxLxKrpzM5NQ8sGuTgUkgP5crOT8vDShYAhSpKs7gKijKL8sEOg2k +HMhNSS1IzUsBcpJAPFAwwUXSM0u4BjoaR8EoGAWjgGQAAILFeyQADAAA +`), + } + + t.Run("RepositoryKey", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", rootURL+"/repository.key") + resp := MakeRequest(t, req, http.StatusOK) + + require.Equal(t, "application/pgp-keys", resp.Header().Get("Content-Type")) + require.Contains(t, resp.Body.String(), "-----BEGIN PGP PUBLIC KEY BLOCK-----") + }) + + for _, group := range []string{"", "arch", "arch/os", "x86_64"} { + groupURL := rootURL + if group != "" { + groupURL = groupURL + "/" + group + } + t.Run(fmt.Sprintf("Upload[%s]", group), func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", groupURL, bytes.NewReader(pkgs["any"])) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", groupURL, bytes.NewReader(pkgs["any"])). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequestWithBody(t, "PUT", groupURL, bytes.NewBuffer([]byte("any string"))). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeArch) + require.NoError(t, err) + require.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + require.Nil(t, pd.SemVer) + require.IsType(t, &arch_model.VersionMetadata{}, pd.Metadata) + require.Equal(t, "test", pd.Package.Name) + require.Equal(t, "1.0.0-1", pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + size := 0 + for _, pf := range pfs { + if pf.CompositeKey == group { + size++ + } + } + require.Equal(t, 2, size) // zst and zst.sig + + pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + require.NoError(t, err) + require.Equal(t, int64(len(pkgs["any"])), pb.Size) + + req = NewRequestWithBody(t, "PUT", groupURL, bytes.NewReader(pkgs["any"])). + AddBasicAuth(user.Name) // exists + MakeRequest(t, req, http.StatusConflict) + req = NewRequestWithBody(t, "PUT", groupURL, bytes.NewReader(pkgs["x86_64"])). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithBody(t, "PUT", groupURL, bytes.NewReader(pkgs["aarch64"])). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + req = NewRequestWithBody(t, "PUT", groupURL, bytes.NewReader(pkgs["aarch64"])). + AddBasicAuth(user.Name) // exists again + MakeRequest(t, req, http.StatusConflict) + }) + + t.Run(fmt.Sprintf("Download[%s]", group), func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequest(t, "GET", groupURL+"/x86_64/test-1.0.0-1-x86_64.pkg.tar.zst") + resp := MakeRequest(t, req, http.StatusOK) + require.Equal(t, pkgs["x86_64"], resp.Body.Bytes()) + + req = NewRequest(t, "GET", groupURL+"/x86_64/test-1.0.0-1-any.pkg.tar.zst") + resp = MakeRequest(t, req, http.StatusOK) + require.Equal(t, pkgs["any"], resp.Body.Bytes()) + + // get other group + req = NewRequest(t, "GET", rootURL+"/unknown/x86_64/test-1.0.0-1-aarch64.pkg.tar.zst") + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run(fmt.Sprintf("SignVerify[%s]", group), func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequest(t, "GET", rootURL+"/repository.key") + respPub := MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", groupURL+"/x86_64/test-1.0.0-1-any.pkg.tar.zst") + respPkg := MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", groupURL+"/x86_64/test-1.0.0-1-any.pkg.tar.zst.sig") + respSig := MakeRequest(t, req, http.StatusOK) + + if err := gpgVerify(respPub.Body.Bytes(), respSig.Body.Bytes(), respPkg.Body.Bytes()); err != nil { + t.Fatal(err) + } + }) + + t.Run(fmt.Sprintf("RepositoryDB[%s]", group), func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequest(t, "GET", rootURL+"/repository.key") + respPub := MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", groupURL+"/x86_64/base.db") + respPkg := MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", groupURL+"/x86_64/base.db.sig") + respSig := MakeRequest(t, req, http.StatusOK) + + if err := gpgVerify(respPub.Body.Bytes(), respSig.Body.Bytes(), respPkg.Body.Bytes()); err != nil { + t.Fatal(err) + } + files, err := listTarGzFiles(respPkg.Body.Bytes()) + require.NoError(t, err) + require.Len(t, files, 1) + for s, d := range files { + name := getProperty(string(d.Data), "NAME") + ver := getProperty(string(d.Data), "VERSION") + require.Equal(t, name+"-"+ver+"/desc", s) + fn := getProperty(string(d.Data), "FILENAME") + pgp := getProperty(string(d.Data), "PGPSIG") + req = NewRequest(t, "GET", groupURL+"/x86_64/"+fn+".sig") + respSig := MakeRequest(t, req, http.StatusOK) + decodeString, err := base64.StdEncoding.DecodeString(pgp) + require.NoError(t, err) + require.Equal(t, respSig.Body.Bytes(), decodeString) + } + }) + + t.Run(fmt.Sprintf("Delete[%s]", group), func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + // test data + req := NewRequestWithBody(t, "PUT", groupURL, bytes.NewReader(pkgs["otherXZ"])). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequestWithBody(t, "DELETE", rootURL+"/base/notfound/1.0.0-1/any", nil). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequestWithBody(t, "DELETE", groupURL+"/test/1.0.0-1/x86_64", nil). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequestWithBody(t, "DELETE", groupURL+"/test/1.0.0-1/any", nil). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "GET", groupURL+"/x86_64/base.db") + respPkg := MakeRequest(t, req, http.StatusOK) + files, err := listTarGzFiles(respPkg.Body.Bytes()) + require.NoError(t, err) + require.Len(t, files, 1) + + req = NewRequestWithBody(t, "DELETE", groupURL+"/test2/1.0.0-1/any", nil). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "GET", groupURL+"/x86_64/base.db"). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequestWithBody(t, "DELETE", groupURL+"/test/1.0.0-1/aarch64", nil). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "GET", groupURL+"/aarch64/base.db"). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + }) + + for tp, key := range map[string]string{ + "GZ": "otherGZ", + "XZ": "otherXZ", + "ZST": "otherZST", + } { + t.Run(fmt.Sprintf("Upload%s[%s]", tp, group), func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequestWithBody(t, "PUT", groupURL, bytes.NewReader(pkgs[key])). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequest(t, "GET", groupURL+"/x86_64/test2-1.0.0-1-any.pkg.tar."+strings.ToLower(tp)) + resp := MakeRequest(t, req, http.StatusOK) + require.Equal(t, pkgs[key], resp.Body.Bytes()) + + req = NewRequestWithBody(t, "DELETE", groupURL+"/test2/1.0.0-1/any", nil). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + }) + } + } + t.Run("Concurrent Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + var wg sync.WaitGroup + + targets := []string{"any", "aarch64", "x86_64"} + for _, tag := range targets { + wg.Add(1) + go func(i string) { + defer wg.Done() + req := NewRequestWithBody(t, "PUT", rootURL, bytes.NewReader(pkgs[i])). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + }(tag) + } + wg.Wait() + for _, target := range targets { + req := NewRequestWithBody(t, "DELETE", rootURL+"/test/1.0.0-1/"+target, nil). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + } + }) + t.Run("Package Arch Test", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequestWithBody(t, "PUT", rootURL, bytes.NewReader(pkgs["any"])). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequest(t, "GET", rootURL+"/x86_64/base.db") + respPkg := MakeRequest(t, req, http.StatusOK) + + files, err := listTarGzFiles(respPkg.Body.Bytes()) + require.NoError(t, err) + require.Len(t, files, 1) + + req = NewRequestWithBody(t, "PUT", rootURL, bytes.NewReader(pkgs["otherXZ"])). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequest(t, "GET", rootURL+"/x86_64/base.db") + respPkg = MakeRequest(t, req, http.StatusOK) + + files, err = listTarGzFiles(respPkg.Body.Bytes()) + require.NoError(t, err) + require.Len(t, files, 2) + }) +} + +func getProperty(data, key string) string { + r := bufio.NewReader(strings.NewReader(data)) + for { + line, _, err := r.ReadLine() + if err != nil { + return "" + } + if strings.Contains(string(line), "%"+key+"%") { + readLine, _, _ := r.ReadLine() + return string(readLine) + } + } +} + +func listTarGzFiles(data []byte) (fstest.MapFS, error) { + reader, err := gzip.NewReader(bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + defer reader.Close() + tarRead := tar.NewReader(reader) + files := make(fstest.MapFS) + for { + cur, err := tarRead.Next() + if err == io.EOF { + break + } else if err != nil { + return nil, err + } + if cur.Typeflag != tar.TypeReg { + continue + } + data, err := io.ReadAll(tarRead) + if err != nil { + return nil, err + } + files[cur.Name] = &fstest.MapFile{Data: data} + } + return files, nil +} + +func gpgVerify(pub, sig, data []byte) error { + sigPack, err := packet.Read(bytes.NewBuffer(sig)) + if err != nil { + return err + } + signature, ok := sigPack.(*packet.Signature) + if !ok { + return errors.New("invalid sign key") + } + pubBlock, err := armor.Decode(bytes.NewReader(pub)) + if err != nil { + return err + } + pack, err := packet.Read(pubBlock.Body) + if err != nil { + return err + } + publicKey, ok := pack.(*packet.PublicKey) + if !ok { + return errors.New("invalid public key") + } + hash := signature.Hash.New() + _, err = hash.Write(data) + if err != nil { + return err + } + return publicKey.VerifySignature(hash, signature) +} diff --git a/tests/integration/api_packages_cargo_test.go b/tests/integration/api_packages_cargo_test.go new file mode 100644 index 0000000..7a9105e --- /dev/null +++ b/tests/integration/api_packages_cargo_test.go @@ -0,0 +1,447 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "net/http" + neturl "net/url" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/json" + cargo_module "code.gitea.io/gitea/modules/packages/cargo" + "code.gitea.io/gitea/modules/setting" + cargo_router "code.gitea.io/gitea/routers/api/packages/cargo" + gitea_context "code.gitea.io/gitea/services/context" + cargo_service "code.gitea.io/gitea/services/packages/cargo" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageCargo(t *testing.T) { + onGiteaRun(t, testPackageCargo) +} + +func testPackageCargo(t *testing.T, _ *neturl.URL) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + packageName := "cargo-package" + packageVersion := "1.0.3" + packageDescription := "Package Description" + packageAuthor := "KN4CK3R" + packageHomepage := "https://gitea.io/" + packageLicense := "MIT" + + createPackage := func(name, version string) io.Reader { + metadata := `{ + "name":"` + name + `", + "vers":"` + version + `", + "description":"` + packageDescription + `", + "authors": ["` + packageAuthor + `"], + "deps":[ + { + "name":"dep", + "version_req":"1.0", + "registry": "https://gitea.io/user/_cargo-index", + "kind": "normal", + "default_features": true + } + ], + "homepage":"` + packageHomepage + `", + "license":"` + packageLicense + `" +}` + + var buf bytes.Buffer + binary.Write(&buf, binary.LittleEndian, uint32(len(metadata))) + buf.WriteString(metadata) + binary.Write(&buf, binary.LittleEndian, uint32(4)) + buf.WriteString("test") + return &buf + } + + err := cargo_service.InitializeIndexRepository(db.DefaultContext, user, user) + require.NoError(t, err) + + repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, user.Name, cargo_service.IndexRepositoryName) + assert.NotNil(t, repo) + require.NoError(t, err) + + readGitContent := func(t *testing.T, path string) string { + gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo) + require.NoError(t, err) + defer gitRepo.Close() + + commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch) + require.NoError(t, err) + + blob, err := commit.GetBlobByPath(path) + require.NoError(t, err) + + content, err := blob.GetBlobContent(1024) + require.NoError(t, err) + + return content + } + + root := fmt.Sprintf("%sapi/packages/%s/cargo", setting.AppURL, user.Name) + url := fmt.Sprintf("%s/api/v1/crates", root) + + t.Run("Index", func(t *testing.T) { + t.Run("Git/Config", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + content := readGitContent(t, cargo_service.ConfigFileName) + + var config cargo_service.Config + err := json.Unmarshal([]byte(content), &config) + require.NoError(t, err) + + assert.Equal(t, url, config.DownloadURL) + assert.Equal(t, root, config.APIURL) + }) + + t.Run("HTTP/Config", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", root+"/"+cargo_service.ConfigFileName) + resp := MakeRequest(t, req, http.StatusOK) + + var config cargo_service.Config + err := json.Unmarshal(resp.Body.Bytes(), &config) + require.NoError(t, err) + + assert.Equal(t, url, config.DownloadURL) + assert.Equal(t, root, config.APIURL) + }) + }) + + t.Run("Upload", func(t *testing.T) { + t.Run("InvalidNameOrVersion", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + content := createPackage("0test", "1.0.0") + + req := NewRequestWithBody(t, "PUT", url+"/new", content). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusBadRequest) + + var status cargo_router.StatusResponse + DecodeJSON(t, resp, &status) + assert.False(t, status.OK) + + content = createPackage("test", "-1.0.0") + + req = NewRequestWithBody(t, "PUT", url+"/new", content). + AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusBadRequest) + + DecodeJSON(t, resp, &status) + assert.False(t, status.OK) + }) + + t.Run("InvalidContent", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + metadata := `{"name":"test","vers":"1.0.0"}` + + var buf bytes.Buffer + binary.Write(&buf, binary.LittleEndian, uint32(len(metadata))) + buf.WriteString(metadata) + binary.Write(&buf, binary.LittleEndian, uint32(4)) + buf.WriteString("te") + + req := NewRequestWithBody(t, "PUT", url+"/new", &buf). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + }) + + t.Run("Valid", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", url+"/new", createPackage(packageName, packageVersion)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", url+"/new", createPackage(packageName, packageVersion)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var status cargo_router.StatusResponse + DecodeJSON(t, resp, &status) + assert.True(t, status.OK) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeCargo) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.NotNil(t, pd.SemVer) + assert.IsType(t, &cargo_module.Metadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, fmt.Sprintf("%s-%s.crate", packageName, packageVersion), pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + require.NoError(t, err) + assert.EqualValues(t, 4, pb.Size) + + req = NewRequestWithBody(t, "PUT", url+"/new", createPackage(packageName, packageVersion)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusConflict) + + t.Run("Index", func(t *testing.T) { + t.Run("Git", func(t *testing.T) { + t.Run("Entry", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + content := readGitContent(t, cargo_service.BuildPackagePath(packageName)) + + var entry cargo_service.IndexVersionEntry + err := json.Unmarshal([]byte(content), &entry) + require.NoError(t, err) + + assert.Equal(t, packageName, entry.Name) + assert.Equal(t, packageVersion, entry.Version) + assert.Equal(t, pb.HashSHA256, entry.FileChecksum) + assert.False(t, entry.Yanked) + assert.Len(t, entry.Dependencies, 1) + dep := entry.Dependencies[0] + assert.Equal(t, "dep", dep.Name) + assert.Equal(t, "1.0", dep.Req) + assert.Equal(t, "normal", dep.Kind) + assert.True(t, dep.DefaultFeatures) + assert.Empty(t, dep.Features) + assert.False(t, dep.Optional) + assert.Nil(t, dep.Target) + assert.NotNil(t, dep.Registry) + assert.Equal(t, "https://gitea.io/user/_cargo-index", *dep.Registry) + assert.Nil(t, dep.Package) + }) + + t.Run("Rebuild", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + err := cargo_service.RebuildIndex(db.DefaultContext, user, user) + require.NoError(t, err) + + _ = readGitContent(t, cargo_service.BuildPackagePath(packageName)) + }) + }) + + t.Run("HTTP", func(t *testing.T) { + t.Run("Entry", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", root+"/"+cargo_service.BuildPackagePath(packageName)) + resp := MakeRequest(t, req, http.StatusOK) + + var entry cargo_service.IndexVersionEntry + err := json.Unmarshal(resp.Body.Bytes(), &entry) + require.NoError(t, err) + + assert.Equal(t, packageName, entry.Name) + assert.Equal(t, packageVersion, entry.Version) + assert.Equal(t, pb.HashSHA256, entry.FileChecksum) + assert.False(t, entry.Yanked) + assert.Len(t, entry.Dependencies, 1) + dep := entry.Dependencies[0] + assert.Equal(t, "dep", dep.Name) + assert.Equal(t, "1.0", dep.Req) + assert.Equal(t, "normal", dep.Kind) + assert.True(t, dep.DefaultFeatures) + assert.Empty(t, dep.Features) + assert.False(t, dep.Optional) + assert.Nil(t, dep.Target) + assert.NotNil(t, dep.Registry) + assert.Equal(t, "https://gitea.io/user/_cargo-index", *dep.Registry) + assert.Nil(t, dep.Package) + }) + }) + }) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeCargo, packageName, packageVersion) + require.NoError(t, err) + assert.EqualValues(t, 0, pv.DownloadCount) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pv.ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/download", url, neturl.PathEscape(packageName), neturl.PathEscape(pv.Version))). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "test", resp.Body.String()) + + pv, err = packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeCargo, packageName, packageVersion) + require.NoError(t, err) + assert.EqualValues(t, 1, pv.DownloadCount) + }) + + t.Run("Search", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + cases := []struct { + Query string + Page int + PerPage int + ExpectedTotal int64 + ExpectedResults int + }{ + {"", 0, 0, 1, 1}, + {"", 1, 10, 1, 1}, + {"cargo", 1, 0, 1, 1}, + {"cargo", 1, 10, 1, 1}, + {"cargo", 2, 10, 1, 0}, + {"test", 0, 10, 0, 0}, + } + + for i, c := range cases { + req := NewRequest(t, "GET", fmt.Sprintf("%s?q=%s&page=%d&per_page=%d", url, c.Query, c.Page, c.PerPage)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result cargo_router.SearchResult + DecodeJSON(t, resp, &result) + + assert.Equal(t, c.ExpectedTotal, result.Meta.Total, "case %d: unexpected total hits", i) + assert.Len(t, result.Crates, c.ExpectedResults, "case %d: unexpected result count", i) + } + }) + + t.Run("Yank", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/yank", url, neturl.PathEscape(packageName), neturl.PathEscape(packageVersion))). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var status cargo_router.StatusResponse + DecodeJSON(t, resp, &status) + assert.True(t, status.OK) + + content := readGitContent(t, cargo_service.BuildPackagePath(packageName)) + + var entry cargo_service.IndexVersionEntry + err := json.Unmarshal([]byte(content), &entry) + require.NoError(t, err) + + assert.True(t, entry.Yanked) + }) + + t.Run("Unyank", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("%s/%s/%s/unyank", url, neturl.PathEscape(packageName), neturl.PathEscape(packageVersion))). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var status cargo_router.StatusResponse + DecodeJSON(t, resp, &status) + assert.True(t, status.OK) + + content := readGitContent(t, cargo_service.BuildPackagePath(packageName)) + + var entry cargo_service.IndexVersionEntry + err := json.Unmarshal([]byte(content), &entry) + require.NoError(t, err) + + assert.False(t, entry.Yanked) + }) + + t.Run("ListOwners", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/owners", url, neturl.PathEscape(packageName))) + resp := MakeRequest(t, req, http.StatusOK) + + var owners cargo_router.Owners + DecodeJSON(t, resp, &owners) + + assert.Len(t, owners.Users, 1) + assert.Equal(t, user.ID, owners.Users[0].ID) + assert.Equal(t, user.Name, owners.Users[0].Login) + assert.Equal(t, user.DisplayName(), owners.Users[0].Name) + }) +} + +func TestRebuildCargo(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *neturl.URL) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + unittest.AssertExistsIf(t, false, &repo_model.Repository{OwnerID: user.ID, Name: cargo_service.IndexRepositoryName}) + + t.Run("No index", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user/settings/packages/cargo/rebuild", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings/packages"), + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "error%3DCannot%2Brebuild%252C%2Bno%2Bindex%2Bis%2Binitialized.", flashCookie.Value) + unittest.AssertExistsIf(t, false, &repo_model.Repository{OwnerID: user.ID, Name: cargo_service.IndexRepositoryName}) + }) + + t.Run("Initialize Cargo", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user/settings/packages") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, `form[action="/user/settings/packages/cargo/rebuild"]`, false) + htmlDoc.AssertElement(t, `form[action="/user/settings/packages/cargo/initialize"]`, true) + + req = NewRequestWithValues(t, "POST", "/user/settings/packages/cargo/initialize", map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + }) + session.MakeRequest(t, req, http.StatusSeeOther) + unittest.AssertExistsIf(t, true, &repo_model.Repository{OwnerID: user.ID, Name: cargo_service.IndexRepositoryName}) + + req = NewRequest(t, "GET", "/user/settings/packages") + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, `form[action="/user/settings/packages/cargo/rebuild"]`, true) + htmlDoc.AssertElement(t, `form[action="/user/settings/packages/cargo/initialize"]`, false) + }) + + t.Run("With index", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user/settings/packages/cargo/rebuild", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings/packages"), + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "success%3DThe%2BCargo%2Bindex%2Bwas%2Bsuccessfully%2Brebuild.", flashCookie.Value) + }) + }) +} diff --git a/tests/integration/api_packages_chef_test.go b/tests/integration/api_packages_chef_test.go new file mode 100644 index 0000000..febb1a8 --- /dev/null +++ b/tests/integration/api_packages_chef_test.go @@ -0,0 +1,562 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "hash" + "math/big" + "mime/multipart" + "net/http" + "path" + "strings" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + chef_module "code.gitea.io/gitea/modules/packages/chef" + "code.gitea.io/gitea/modules/setting" + chef_router "code.gitea.io/gitea/routers/api/packages/chef" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageChef(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + privPem := `-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAtWp2PZz4TSU5A6ixw41HdbfBuGJwPuTtrsdoUf0DQ0/DJBNP +qOCBAgEu6ZdUqIbWJ5Da+nevjtncy5hENdi6XrXjyzlUxghMuXjE5SeLGpgfQvkq +bTkYaFpMe8PTzNeze3fei8+Eu6mzeb6g1GrqXznuPIc7bNss0w5iX9RiBM9dWPuX +onx9xSEy0LYqJm7yXmshNe1aRwkjG/y5C26BzBFnMKp9YRTua0DO1WqLNhcaRnda +lIFYouDNVTbwxSlYL16bZVoebqzZvLGrPvZJkPuCu6vH9brvOuYo0q8hLVNkBeXc +imRpsDjLhQYzEJjoMTbaiVGnjBky+PWNiofJnwIDAQABAoIBAQCotF1KxLt/ejr/ +9ROCh9JJXV3v6tL5GgkSPOv9Oq2bHgSZer/cixJNW+5VWd5nbiSe3K1WuJBw5pbW +Wj4sWORPiRRR+3mjQzqeS/nGJDTOwWJo9K8IrUzOVhLEEYLX/ksxaXJyT8PehFyb +vbNwdhCIB6ZNcXDItTWE+95twWJ5lxAIj2dNwZZni3UkwwjYnCnqFtvHCKOg0NH2 +RjQcFYmu3fncNeqLezUSdVyRyXxSCHsUdlYeX/e44StCnXdrmLUHlb2P27ZVdPGh +SW7qTUPpmJKekYiRPOpTLj+ZKXIsANkyWO+7dVtZLBm5bIyAsmp0W/DmK+wRsejj +alFbIsh5AoGBANJr7HSG695wkfn+kvu/V8qHbt+KDv4WjWHjGRsUqvxoHOUNkQmW +vZWdk4gjHYn1l+QHWmoOE3AgyqtCZ4bFILkZPLN/F8Mh3+r4B0Ac4biJJt7XGMNQ +Nv4wsk7TR7CCARsjO7GP1PT60hpjMvYmc1E36gNM7QIZE9jBE+L8eWYtAoGBANy2 +JOAWf+QeBlur6o9feH76cEmpQzUUq4Lj9mmnXgIirSsFoBnDb8VA6Ws+ltL9U9H2 +vaCoaTyi9twW9zWj+Ywg2mVR5nlSAPfdlTWS1GLUbDotlj5apc/lvnGuNlWzN+I4 +Tu64hhgBXqGvRZ0o7HzFodqRAkpVXp6CQCqBM7p7AoGAIgO0K3oL8t87ma/fTra1 +mFWgRJ5qogQ/Qo2VZ11F7ptd4GD7CxPE/cSFLsKOadi7fu75XJ994OhMGrcXSR/g +lEtSFqn6y15UdgU2FtUUX+I72FXo+Nmkqh5xFHDu68d4Kkzdv2xCvn81K3LRsByz +E3P4biQnQ+mN3cIIVu79KNkCgYEAm6uctrEn4y2KLn5DInyj8GuTZ2ELFhVOIzPG +SR7TH451tTJyiblezDHMcOfkWUx0IlN1zCr8jtgiZXmNQzg0erFxWKU7ebZtGGYh +J3g4dLx+2Unt/mzRJqFUgbnueOO/Nr+gbJ+ZdLUCmeeVohOLOTXrws0kYGl2Izab +K1+VrKECgYEAxQohoOegA0f4mofisXItbwwqTIX3bLpxBc4woa1sB4kjNrLo4slc +qtWZGVlRxwBvQUg0cYj+xtr5nyBdHLy0qwX/kMq4GqQnvW6NqsbrP3MjCZ8NX/Sj +A2W0jx50Hs/XNw6IZFLYgWVoOzCaD+jYFpHhzUZyQD6/rYhwhHrNQmU= +-----END RSA PRIVATE KEY-----` + + tmp, _ := pem.Decode([]byte(privPem)) + privKey, _ := x509.ParsePKCS1PrivateKey(tmp.Bytes) + + pubPem := `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtWp2PZz4TSU5A6ixw41H +dbfBuGJwPuTtrsdoUf0DQ0/DJBNPqOCBAgEu6ZdUqIbWJ5Da+nevjtncy5hENdi6 +XrXjyzlUxghMuXjE5SeLGpgfQvkqbTkYaFpMe8PTzNeze3fei8+Eu6mzeb6g1Grq +XznuPIc7bNss0w5iX9RiBM9dWPuXonx9xSEy0LYqJm7yXmshNe1aRwkjG/y5C26B +zBFnMKp9YRTua0DO1WqLNhcaRndalIFYouDNVTbwxSlYL16bZVoebqzZvLGrPvZJ +kPuCu6vH9brvOuYo0q8hLVNkBeXcimRpsDjLhQYzEJjoMTbaiVGnjBky+PWNiofJ +nwIDAQAB +-----END PUBLIC KEY-----` + + err := user_model.SetUserSetting(db.DefaultContext, user.ID, chef_module.SettingPublicPem, pubPem) + require.NoError(t, err) + + t.Run("Authenticate", func(t *testing.T) { + auth := &chef_router.Auth{} + + t.Run("MissingUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "POST", "/dummy") + u, err := auth.Verify(req.Request, nil, nil, nil) + assert.Nil(t, u) + require.NoError(t, err) + }) + + t.Run("NotExistingUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "POST", "/dummy"). + SetHeader("X-Ops-Userid", "not-existing-user") + u, err := auth.Verify(req.Request, nil, nil, nil) + assert.Nil(t, u) + require.Error(t, err) + }) + + t.Run("Timestamp", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "POST", "/dummy"). + SetHeader("X-Ops-Userid", user.Name) + u, err := auth.Verify(req.Request, nil, nil, nil) + assert.Nil(t, u) + require.Error(t, err) + + req.SetHeader("X-Ops-Timestamp", "2023-01-01T00:00:00Z") + u, err = auth.Verify(req.Request, nil, nil, nil) + assert.Nil(t, u) + require.Error(t, err) + }) + + t.Run("SigningVersion", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "POST", "/dummy"). + SetHeader("X-Ops-Userid", user.Name). + SetHeader("X-Ops-Timestamp", time.Now().UTC().Format(time.RFC3339)) + u, err := auth.Verify(req.Request, nil, nil, nil) + assert.Nil(t, u) + require.Error(t, err) + + req.SetHeader("X-Ops-Sign", "version=none") + u, err = auth.Verify(req.Request, nil, nil, nil) + assert.Nil(t, u) + require.Error(t, err) + + req.SetHeader("X-Ops-Sign", "version=1.4") + u, err = auth.Verify(req.Request, nil, nil, nil) + assert.Nil(t, u) + require.Error(t, err) + + req.SetHeader("X-Ops-Sign", "version=1.0;algorithm=sha2") + u, err = auth.Verify(req.Request, nil, nil, nil) + assert.Nil(t, u) + require.Error(t, err) + + req.SetHeader("X-Ops-Sign", "version=1.0;algorithm=sha256") + u, err = auth.Verify(req.Request, nil, nil, nil) + assert.Nil(t, u) + require.Error(t, err) + }) + + t.Run("SignedHeaders", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + ts := time.Now().UTC().Format(time.RFC3339) + + req := NewRequest(t, "POST", "/dummy"). + SetHeader("X-Ops-Userid", user.Name). + SetHeader("X-Ops-Timestamp", ts). + SetHeader("X-Ops-Sign", "version=1.0;algorithm=sha1"). + SetHeader("X-Ops-Content-Hash", "unused"). + SetHeader("X-Ops-Authorization-4", "dummy") + u, err := auth.Verify(req.Request, nil, nil, nil) + assert.Nil(t, u) + require.Error(t, err) + + signRequest := func(rw *RequestWrapper, version string) { + req := rw.Request + username := req.Header.Get("X-Ops-Userid") + if version != "1.0" && version != "1.3" { + sum := sha1.Sum([]byte(username)) + username = base64.StdEncoding.EncodeToString(sum[:]) + } + + req.Header.Set("X-Ops-Sign", "version="+version) + + var data []byte + if version == "1.3" { + data = []byte(fmt.Sprintf( + "Method:%s\nPath:%s\nX-Ops-Content-Hash:%s\nX-Ops-Sign:version=%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s\nX-Ops-Server-API-Version:%s", + req.Method, + path.Clean(req.URL.Path), + req.Header.Get("X-Ops-Content-Hash"), + version, + req.Header.Get("X-Ops-Timestamp"), + username, + req.Header.Get("X-Ops-Server-Api-Version"), + )) + } else { + sum := sha1.Sum([]byte(path.Clean(req.URL.Path))) + data = []byte(fmt.Sprintf( + "Method:%s\nHashed Path:%s\nX-Ops-Content-Hash:%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s", + req.Method, + base64.StdEncoding.EncodeToString(sum[:]), + req.Header.Get("X-Ops-Content-Hash"), + req.Header.Get("X-Ops-Timestamp"), + username, + )) + } + + for k := range req.Header { + if strings.HasPrefix(k, "X-Ops-Authorization-") { + req.Header.Del(k) + } + } + + var signature []byte + if version == "1.3" || version == "1.2" { + var h hash.Hash + var ch crypto.Hash + if version == "1.3" { + h = sha256.New() + ch = crypto.SHA256 + } else { + h = sha1.New() + ch = crypto.SHA1 + } + h.Write(data) + + signature, _ = rsa.SignPKCS1v15(rand.Reader, privKey, ch, h.Sum(nil)) + } else { + c := new(big.Int).SetBytes(data) + m := new(big.Int).Exp(c, privKey.D, privKey.N) + + signature = m.Bytes() + } + + enc := base64.StdEncoding.EncodeToString(signature) + + const chunkSize = 60 + chunks := make([]string, 0, (len(enc)-1)/chunkSize+1) + currentLen := 0 + currentStart := 0 + for i := range enc { + if currentLen == chunkSize { + chunks = append(chunks, enc[currentStart:i]) + currentLen = 0 + currentStart = i + } + currentLen++ + } + chunks = append(chunks, enc[currentStart:]) + + for i, chunk := range chunks { + req.Header.Set(fmt.Sprintf("X-Ops-Authorization-%d", i+1), chunk) + } + } + + for _, v := range []string{"1.0", "1.1", "1.2", "1.3"} { + t.Run(v, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + signRequest(req, v) + u, err = auth.Verify(req.Request, nil, nil, nil) + assert.NotNil(t, u) + require.NoError(t, err) + }) + } + }) + }) + + packageName := "test" + packageVersion := "1.0.1" + packageDescription := "Test Description" + packageAuthor := "KN4CK3R" + + root := fmt.Sprintf("/api/packages/%s/chef/api/v1", user.Name) + + uploadPackage := func(t *testing.T, version string, expectedStatus int) { + var body bytes.Buffer + mpw := multipart.NewWriter(&body) + part, _ := mpw.CreateFormFile("tarball", fmt.Sprintf("%s.tar.gz", version)) + zw := gzip.NewWriter(part) + tw := tar.NewWriter(zw) + + content := `{"name":"` + packageName + `","version":"` + version + `","description":"` + packageDescription + `","maintainer":"` + packageAuthor + `"}` + + hdr := &tar.Header{ + Name: packageName + "/metadata.json", + Mode: 0o600, + Size: int64(len(content)), + } + tw.WriteHeader(hdr) + tw.Write([]byte(content)) + + tw.Close() + zw.Close() + mpw.Close() + + req := NewRequestWithBody(t, "POST", root+"/cookbooks", &body). + SetHeader("Content-Type", mpw.FormDataContentType()). + AddBasicAuth(user.Name) + MakeRequest(t, req, expectedStatus) + } + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "POST", root+"/cookbooks", bytes.NewReader([]byte{})) + MakeRequest(t, req, http.StatusUnauthorized) + + uploadPackage(t, packageVersion, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeChef) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.NotNil(t, pd.SemVer) + assert.IsType(t, &chef_module.Metadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, fmt.Sprintf("%s.tar.gz", packageVersion), pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + uploadPackage(t, packageVersion, http.StatusConflict) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/cookbooks/%s/versions/%s/download", root, packageName, packageVersion)) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("Universe", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", root+"/universe") + resp := MakeRequest(t, req, http.StatusOK) + + type VersionInfo struct { + LocationType string `json:"location_type"` + LocationPath string `json:"location_path"` + DownloadURL string `json:"download_url"` + Dependencies map[string]string `json:"dependencies"` + } + + var result map[string]map[string]*VersionInfo + DecodeJSON(t, resp, &result) + + assert.Len(t, result, 1) + assert.Contains(t, result, packageName) + + versions := result[packageName] + + assert.Len(t, versions, 1) + assert.Contains(t, versions, packageVersion) + + info := versions[packageVersion] + + assert.Equal(t, "opscode", info.LocationType) + assert.Equal(t, setting.AppURL+root[1:], info.LocationPath) + assert.Equal(t, fmt.Sprintf("%s%s/cookbooks/%s/versions/%s/download", setting.AppURL, root[1:], packageName, packageVersion), info.DownloadURL) + }) + + t.Run("Search", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + cases := []struct { + Query string + Start int + Items int + ExpectedTotal int + ExpectedResults int + }{ + {"", 0, 0, 1, 1}, + {"", 0, 10, 1, 1}, + {"gitea", 0, 10, 0, 0}, + {"test", 0, 10, 1, 1}, + {"test", 1, 10, 1, 0}, + } + + type Item struct { + CookbookName string `json:"cookbook_name"` + CookbookMaintainer string `json:"cookbook_maintainer"` + CookbookDescription string `json:"cookbook_description"` + Cookbook string `json:"cookbook"` + } + + type Result struct { + Start int `json:"start"` + Total int `json:"total"` + Items []*Item `json:"items"` + } + + for i, c := range cases { + req := NewRequest(t, "GET", fmt.Sprintf("%s/search?q=%s&start=%d&items=%d", root, c.Query, c.Start, c.Items)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result Result + DecodeJSON(t, resp, &result) + + assert.Equal(t, c.ExpectedTotal, result.Total, "case %d: unexpected total hits", i) + assert.Len(t, result.Items, c.ExpectedResults, "case %d: unexpected result count", i) + + if len(result.Items) == 1 { + item := result.Items[0] + assert.Equal(t, packageName, item.CookbookName) + assert.Equal(t, packageAuthor, item.CookbookMaintainer) + assert.Equal(t, packageDescription, item.CookbookDescription) + assert.Equal(t, fmt.Sprintf("%s%s/cookbooks/%s", setting.AppURL, root[1:], packageName), item.Cookbook) + } + } + }) + + t.Run("EnumeratePackages", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + cases := []struct { + Sort string + Start int + Items int + ExpectedTotal int + ExpectedResults int + }{ + {"", 0, 0, 1, 1}, + {"", 0, 10, 1, 1}, + {"RECENTLY_ADDED", 0, 10, 1, 1}, + {"RECENTLY_UPDATED", 0, 10, 1, 1}, + {"", 1, 10, 1, 0}, + } + + type Item struct { + CookbookName string `json:"cookbook_name"` + CookbookMaintainer string `json:"cookbook_maintainer"` + CookbookDescription string `json:"cookbook_description"` + Cookbook string `json:"cookbook"` + } + + type Result struct { + Start int `json:"start"` + Total int `json:"total"` + Items []*Item `json:"items"` + } + + for i, c := range cases { + req := NewRequest(t, "GET", fmt.Sprintf("%s/cookbooks?start=%d&items=%d&sort=%s", root, c.Start, c.Items, c.Sort)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result Result + DecodeJSON(t, resp, &result) + + assert.Equal(t, c.ExpectedTotal, result.Total, "case %d: unexpected total hits", i) + assert.Len(t, result.Items, c.ExpectedResults, "case %d: unexpected result count", i) + + if len(result.Items) == 1 { + item := result.Items[0] + assert.Equal(t, packageName, item.CookbookName) + assert.Equal(t, packageAuthor, item.CookbookMaintainer) + assert.Equal(t, packageDescription, item.CookbookDescription) + assert.Equal(t, fmt.Sprintf("%s%s/cookbooks/%s", setting.AppURL, root[1:], packageName), item.Cookbook) + } + } + }) + + t.Run("PackageMetadata", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/cookbooks/%s", root, packageName)) + resp := MakeRequest(t, req, http.StatusOK) + + type Result struct { + Name string `json:"name"` + Maintainer string `json:"maintainer"` + Description string `json:"description"` + Category string `json:"category"` + LatestVersion string `json:"latest_version"` + SourceURL string `json:"source_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Deprecated bool `json:"deprecated"` + Versions []string `json:"versions"` + } + + var result Result + DecodeJSON(t, resp, &result) + + versionURL := fmt.Sprintf("%s%s/cookbooks/%s/versions/%s", setting.AppURL, root[1:], packageName, packageVersion) + + assert.Equal(t, packageName, result.Name) + assert.Equal(t, packageAuthor, result.Maintainer) + assert.Equal(t, packageDescription, result.Description) + assert.Equal(t, versionURL, result.LatestVersion) + assert.False(t, result.Deprecated) + assert.ElementsMatch(t, []string{versionURL}, result.Versions) + }) + + t.Run("PackageVersionMetadata", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/cookbooks/%s/versions/%s", root, packageName, packageVersion)) + resp := MakeRequest(t, req, http.StatusOK) + + type Result struct { + Version string `json:"version"` + TarballFileSize int64 `json:"tarball_file_size"` + PublishedAt time.Time `json:"published_at"` + Cookbook string `json:"cookbook"` + File string `json:"file"` + License string `json:"license"` + Dependencies map[string]string `json:"dependencies"` + } + + var result Result + DecodeJSON(t, resp, &result) + + packageURL := fmt.Sprintf("%s%s/cookbooks/%s", setting.AppURL, root[1:], packageName) + + assert.Equal(t, packageVersion, result.Version) + assert.Equal(t, packageURL, result.Cookbook) + assert.Equal(t, fmt.Sprintf("%s/versions/%s/download", packageURL, packageVersion), result.File) + }) + + t.Run("Delete", func(t *testing.T) { + uploadPackage(t, "1.0.2", http.StatusCreated) + uploadPackage(t, "1.0.3", http.StatusCreated) + + t.Run("Version", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/cookbooks/%s/versions/%s", root, packageName, "1.0.2")) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/cookbooks/%s/versions/%s", root, packageName, "1.0.2")). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeChef, packageName, "1.0.2") + assert.Nil(t, pv) + require.Error(t, err) + }) + + t.Run("Package", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/cookbooks/%s", root, packageName)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/cookbooks/%s", root, packageName)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeChef) + require.NoError(t, err) + assert.Empty(t, pvs) + }) + }) +} diff --git a/tests/integration/api_packages_composer_test.go b/tests/integration/api_packages_composer_test.go new file mode 100644 index 0000000..9d25cc4 --- /dev/null +++ b/tests/integration/api_packages_composer_test.go @@ -0,0 +1,222 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/zip" + "bytes" + "fmt" + "net/http" + neturl "net/url" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + composer_module "code.gitea.io/gitea/modules/packages/composer" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers/api/packages/composer" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageComposer(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + vendorName := "gitea" + projectName := "composer-package" + packageName := vendorName + "/" + projectName + packageVersion := "1.0.3" + packageDescription := "Package Description" + packageType := "composer-plugin" + packageAuthor := "Gitea Authors" + packageLicense := "MIT" + packageBin := "./bin/script" + + var buf bytes.Buffer + archive := zip.NewWriter(&buf) + w, _ := archive.Create("composer.json") + w.Write([]byte(`{ + "name": "` + packageName + `", + "description": "` + packageDescription + `", + "type": "` + packageType + `", + "license": "` + packageLicense + `", + "authors": [ + { + "name": "` + packageAuthor + `" + } + ], + "bin": [ + "` + packageBin + `" + ] + }`)) + archive.Close() + content := buf.Bytes() + + url := fmt.Sprintf("%sapi/packages/%s/composer", setting.AppURL, user.Name) + + t.Run("ServiceIndex", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/packages.json", url)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result composer.ServiceIndexResponse + DecodeJSON(t, resp, &result) + + assert.Equal(t, url+"/search.json?q=%query%&type=%type%", result.SearchTemplate) + assert.Equal(t, url+"/p2/%package%.json", result.MetadataTemplate) + assert.Equal(t, url+"/list.json", result.PackageList) + }) + + t.Run("Upload", func(t *testing.T) { + t.Run("MissingVersion", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", url, bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + }) + + t.Run("Valid", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadURL := url + "?version=" + packageVersion + + req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeComposer) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.NotNil(t, pd.SemVer) + assert.IsType(t, &composer_module.Metadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, fmt.Sprintf("%s-%s.%s.zip", vendorName, projectName, packageVersion), pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + require.NoError(t, err) + assert.Equal(t, int64(len(content)), pb.Size) + + req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusConflict) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeComposer) + require.NoError(t, err) + assert.Len(t, pvs, 1) + assert.Equal(t, int64(0), pvs[0].DownloadCount) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + + req := NewRequest(t, "GET", fmt.Sprintf("%s/files/%s/%s/%s", url, neturl.PathEscape(packageName), neturl.PathEscape(pvs[0].LowerVersion), neturl.PathEscape(pfs[0].LowerName))). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, content, resp.Body.Bytes()) + + pvs, err = packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeComposer) + require.NoError(t, err) + assert.Len(t, pvs, 1) + assert.Equal(t, int64(1), pvs[0].DownloadCount) + }) + + t.Run("SearchService", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + cases := []struct { + Query string + Type string + Page int + PerPage int + ExpectedTotal int64 + ExpectedResults int + }{ + {"", "", 0, 0, 1, 1}, + {"", "", 1, 1, 1, 1}, + {"test", "", 1, 0, 0, 0}, + {"gitea", "", 1, 1, 1, 1}, + {"gitea", "", 2, 1, 1, 0}, + {"", packageType, 1, 1, 1, 1}, + {"gitea", packageType, 1, 1, 1, 1}, + {"gitea", "dummy", 1, 1, 0, 0}, + } + + for i, c := range cases { + req := NewRequest(t, "GET", fmt.Sprintf("%s/search.json?q=%s&type=%s&page=%d&per_page=%d", url, c.Query, c.Type, c.Page, c.PerPage)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result composer.SearchResultResponse + DecodeJSON(t, resp, &result) + + assert.Equal(t, c.ExpectedTotal, result.Total, "case %d: unexpected total hits", i) + assert.Len(t, result.Results, c.ExpectedResults, "case %d: unexpected result count", i) + } + }) + + t.Run("EnumeratePackages", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", url+"/list.json"). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result map[string][]string + DecodeJSON(t, resp, &result) + + assert.Contains(t, result, "packageNames") + names := result["packageNames"] + assert.Len(t, names, 1) + assert.Equal(t, packageName, names[0]) + }) + + t.Run("PackageMetadata", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/p2/%s/%s.json", url, vendorName, projectName)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result composer.PackageMetadataResponse + DecodeJSON(t, resp, &result) + + assert.Contains(t, result.Packages, packageName) + pkgs := result.Packages[packageName] + assert.Len(t, pkgs, 1) + assert.Equal(t, packageName, pkgs[0].Name) + assert.Equal(t, packageVersion, pkgs[0].Version) + assert.Equal(t, packageType, pkgs[0].Type) + assert.Equal(t, packageDescription, pkgs[0].Description) + assert.Len(t, pkgs[0].Authors, 1) + assert.Equal(t, packageAuthor, pkgs[0].Authors[0].Name) + assert.Equal(t, "zip", pkgs[0].Dist.Type) + assert.Equal(t, "4f5fa464c3cb808a1df191dbf6cb75363f8b7072", pkgs[0].Dist.Checksum) + assert.Len(t, pkgs[0].Bin, 1) + assert.Equal(t, packageBin, pkgs[0].Bin[0]) + }) +} diff --git a/tests/integration/api_packages_conan_test.go b/tests/integration/api_packages_conan_test.go new file mode 100644 index 0000000..9d8f435 --- /dev/null +++ b/tests/integration/api_packages_conan_test.go @@ -0,0 +1,793 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + stdurl "net/url" + "strings" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + conan_model "code.gitea.io/gitea/models/packages/conan" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + conan_module "code.gitea.io/gitea/modules/packages/conan" + "code.gitea.io/gitea/modules/setting" + conan_router "code.gitea.io/gitea/routers/api/packages/conan" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + conanfileName = "conanfile.py" + conaninfoName = "conaninfo.txt" + + conanLicense = "MIT" + conanAuthor = "Gitea <info@gitea.io>" + conanHomepage = "https://gitea.io/" + conanURL = "https://gitea.com/" + conanDescription = "Description of ConanPackage" + conanTopic = "gitea" + + conanPackageReference = "dummyreference" + + contentConaninfo = `[settings] + arch=x84_64 + +[requires] + fmt/7.1.3 + +[options] + shared=False + +[full_settings] + arch=x84_64 + +[full_requires] + fmt/7.1.3 + +[full_options] + shared=False + +[recipe_hash] + 74714915a51073acb548ca1ce29afbac + +[env] +CC=gcc-10` +) + +func buildConanfileContent(name, version string) string { + return `from conans import ConanFile, CMake, tools + +class ConanPackageConan(ConanFile): + name = "` + name + `" + version = "` + version + `" + license = "` + conanLicense + `" + author = "` + conanAuthor + `" + homepage = "` + conanHomepage + `" + url = "` + conanURL + `" + description = "` + conanDescription + `" + topics = ("` + conanTopic + `") + settings = "os", "compiler", "build_type", "arch" + options = {"shared": [True, False], "fPIC": [True, False]} + default_options = {"shared": False, "fPIC": True} + generators = "cmake"` +} + +func uploadConanPackageV1(t *testing.T, baseURL, token, name, version, user, channel string) { + contentConanfile := buildConanfileContent(name, version) + + recipeURL := fmt.Sprintf("%s/v1/conans/%s/%s/%s/%s", baseURL, name, version, user, channel) + + req := NewRequest(t, "GET", recipeURL). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/digest", recipeURL)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/download_urls", recipeURL)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "POST", fmt.Sprintf("%s/upload_urls", recipeURL)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("%s/upload_urls", recipeURL), map[string]int64{ + conanfileName: int64(len(contentConanfile)), + "removed.txt": 0, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + uploadURLs := make(map[string]string) + DecodeJSON(t, resp, &uploadURLs) + + assert.Contains(t, uploadURLs, conanfileName) + assert.NotContains(t, uploadURLs, "removed.txt") + + uploadURL := uploadURLs[conanfileName] + assert.NotEmpty(t, uploadURL) + + req = NewRequestWithBody(t, "PUT", uploadURL, strings.NewReader(contentConanfile)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + packageURL := fmt.Sprintf("%s/packages/%s", recipeURL, conanPackageReference) + + req = NewRequest(t, "GET", packageURL). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/digest", packageURL)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/download_urls", packageURL)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "POST", fmt.Sprintf("%s/upload_urls", packageURL)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("%s/upload_urls", packageURL), map[string]int64{ + conaninfoName: int64(len(contentConaninfo)), + "removed.txt": 0, + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + uploadURLs = make(map[string]string) + DecodeJSON(t, resp, &uploadURLs) + + assert.Contains(t, uploadURLs, conaninfoName) + assert.NotContains(t, uploadURLs, "removed.txt") + + uploadURL = uploadURLs[conaninfoName] + assert.NotEmpty(t, uploadURL) + + req = NewRequestWithBody(t, "PUT", uploadURL, strings.NewReader(contentConaninfo)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) +} + +func uploadConanPackageV2(t *testing.T, baseURL, token, name, version, user, channel, recipeRevision, packageRevision string) { + contentConanfile := buildConanfileContent(name, version) + + recipeURL := fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions/%s", baseURL, name, version, user, channel, recipeRevision) + + req := NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/files/%s", recipeURL, conanfileName), strings.NewReader(contentConanfile)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/files", recipeURL)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var list *struct { + Files map[string]any `json:"files"` + } + DecodeJSON(t, resp, &list) + assert.Len(t, list.Files, 1) + assert.Contains(t, list.Files, conanfileName) + + packageURL := fmt.Sprintf("%s/packages/%s/revisions/%s", recipeURL, conanPackageReference, packageRevision) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/files", packageURL)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/files/%s", packageURL, conaninfoName), strings.NewReader(contentConaninfo)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/files", packageURL)). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + list = nil + DecodeJSON(t, resp, &list) + assert.Len(t, list.Files, 1) + assert.Contains(t, list.Files, conaninfoName) +} + +func TestPackageConan(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + name := "ConanPackage" + version1 := "1.2" + version2 := "1.3" + user1 := "dummy" + user2 := "gitea" + channel1 := "test" + channel2 := "final" + revision1 := "rev1" + revision2 := "rev2" + + url := fmt.Sprintf("%sapi/packages/%s/conan", setting.AppURL, user.Name) + + t.Run("v1", func(t *testing.T) { + t.Run("Ping", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/v1/ping", url)) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "revisions", resp.Header().Get("X-Conan-Server-Capabilities")) + }) + + t.Run("Token Scope Authentication", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, user.Name) + + testCase := func(t *testing.T, scope auth_model.AccessTokenScope, expectedStatusCode int) { + t.Helper() + + token := getTokenForLoggedInUser(t, session, scope) + + req := NewRequest(t, "GET", fmt.Sprintf("%s/v1/users/authenticate", url)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + body := resp.Body.String() + assert.NotEmpty(t, body) + + recipeURL := fmt.Sprintf("%s/v1/conans/%s/%s/%s/%s", url, "TestScope", version1, "testing", channel1) + + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("%s/upload_urls", recipeURL), map[string]int64{ + conanfileName: 64, + "removed.txt": 0, + }).AddTokenAuth(token) + MakeRequest(t, req, expectedStatusCode) + } + + t.Run("Read permission", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testCase(t, auth_model.AccessTokenScopeReadPackage, http.StatusUnauthorized) + }) + + t.Run("Write permission", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testCase(t, auth_model.AccessTokenScopeWritePackage, http.StatusOK) + }) + }) + + token := "" + + t.Run("Authenticate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/v1/users/authenticate", url)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + token = resp.Body.String() + assert.NotEmpty(t, token) + }) + + t.Run("CheckCredentials", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/v1/users/check_credentials", url)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadConanPackageV1(t, url, token, name, version1, user1, channel1) + + t.Run("Validate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeConan) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.Nil(t, pd.SemVer) + assert.Equal(t, name, pd.Package.Name) + assert.Equal(t, version1, pd.Version.Version) + assert.IsType(t, &conan_module.Metadata{}, pd.Metadata) + metadata := pd.Metadata.(*conan_module.Metadata) + assert.Equal(t, conanLicense, metadata.License) + assert.Equal(t, conanAuthor, metadata.Author) + assert.Equal(t, conanHomepage, metadata.ProjectURL) + assert.Equal(t, conanURL, metadata.RepositoryURL) + assert.Equal(t, conanDescription, metadata.Description) + assert.Equal(t, []string{conanTopic}, metadata.Keywords) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 2) + + for _, pf := range pfs { + pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID) + require.NoError(t, err) + + if pf.Name == conanfileName { + assert.True(t, pf.IsLead) + + assert.Equal(t, int64(len(buildConanfileContent(name, version1))), pb.Size) + } else if pf.Name == conaninfoName { + assert.False(t, pf.IsLead) + + assert.Equal(t, int64(len(contentConaninfo)), pb.Size) + } else { + assert.FailNow(t, "unknown file: %s", pf.Name) + } + } + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + recipeURL := fmt.Sprintf("%s/v1/conans/%s/%s/%s/%s", url, name, version1, user1, channel1) + + req := NewRequest(t, "GET", recipeURL) + resp := MakeRequest(t, req, http.StatusOK) + + fileHashes := make(map[string]string) + DecodeJSON(t, resp, &fileHashes) + assert.Len(t, fileHashes, 1) + assert.Contains(t, fileHashes, conanfileName) + assert.Equal(t, "7abc52241c22090782c54731371847a8", fileHashes[conanfileName]) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/digest", recipeURL)) + resp = MakeRequest(t, req, http.StatusOK) + + downloadURLs := make(map[string]string) + DecodeJSON(t, resp, &downloadURLs) + assert.Contains(t, downloadURLs, conanfileName) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/download_urls", recipeURL)) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &downloadURLs) + assert.Contains(t, downloadURLs, conanfileName) + + req = NewRequest(t, "GET", downloadURLs[conanfileName]) + resp = MakeRequest(t, req, http.StatusOK) + assert.Equal(t, buildConanfileContent(name, version1), resp.Body.String()) + + packageURL := fmt.Sprintf("%s/packages/%s", recipeURL, conanPackageReference) + + req = NewRequest(t, "GET", packageURL) + resp = MakeRequest(t, req, http.StatusOK) + + fileHashes = make(map[string]string) + DecodeJSON(t, resp, &fileHashes) + assert.Len(t, fileHashes, 1) + assert.Contains(t, fileHashes, conaninfoName) + assert.Equal(t, "7628bfcc5b17f1470c468621a78df394", fileHashes[conaninfoName]) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/digest", packageURL)) + resp = MakeRequest(t, req, http.StatusOK) + + downloadURLs = make(map[string]string) + DecodeJSON(t, resp, &downloadURLs) + assert.Contains(t, downloadURLs, conaninfoName) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/download_urls", packageURL)) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &downloadURLs) + assert.Contains(t, downloadURLs, conaninfoName) + + req = NewRequest(t, "GET", downloadURLs[conaninfoName]) + resp = MakeRequest(t, req, http.StatusOK) + assert.Equal(t, contentConaninfo, resp.Body.String()) + }) + + t.Run("Search", func(t *testing.T) { + uploadConanPackageV1(t, url, token, name, version2, user1, channel1) + uploadConanPackageV1(t, url, token, name, version1, user1, channel2) + uploadConanPackageV1(t, url, token, name, version1, user2, channel1) + uploadConanPackageV1(t, url, token, name, version1, user2, channel2) + + t.Run("Recipe", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + cases := []struct { + Query string + Expected []string + }{ + {"ConanPackage", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/1.2", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/1.1", []string{}}, + {"Conan*", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/*", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/1*", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/*2", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/1*2", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/1.2@", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/1.2@du*", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@dummy/final"}}, + {"ConanPackage/1.2@du*/", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@dummy/final"}}, + {"ConanPackage/1.2@du*/*test", []string{"ConanPackage/1.2@dummy/test"}}, + {"ConanPackage/1.2@du*/*st", []string{"ConanPackage/1.2@dummy/test"}}, + {"ConanPackage/1.2@gitea/*", []string{"ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"*/*@dummy", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@dummy/final"}}, + {"*/*@*/final", []string{"ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/final"}}, + } + + for i, c := range cases { + req := NewRequest(t, "GET", fmt.Sprintf("%s/v1/conans/search?q=%s", url, stdurl.QueryEscape(c.Query))) + resp := MakeRequest(t, req, http.StatusOK) + + var result *conan_router.SearchResult + DecodeJSON(t, resp, &result) + + assert.ElementsMatch(t, c.Expected, result.Results, "case %d: unexpected result", i) + } + }) + + t.Run("Package", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/v1/conans/%s/%s/%s/%s/search", url, name, version1, user1, channel2)) + resp := MakeRequest(t, req, http.StatusOK) + + var result map[string]*conan_module.Conaninfo + DecodeJSON(t, resp, &result) + + assert.Contains(t, result, conanPackageReference) + info := result[conanPackageReference] + assert.NotEmpty(t, info.Settings) + }) + }) + + t.Run("Delete", func(t *testing.T) { + t.Run("Package", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + cases := []struct { + Channel string + References []string + }{ + {channel1, []string{conanPackageReference}}, + {channel2, []string{}}, + } + + for i, c := range cases { + rref, _ := conan_module.NewRecipeReference(name, version1, user1, c.Channel, conan_module.DefaultRevision) + references, err := conan_model.GetPackageReferences(db.DefaultContext, user.ID, rref) + require.NoError(t, err) + assert.NotEmpty(t, references) + + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("%s/v1/conans/%s/%s/%s/%s/packages/delete", url, name, version1, user1, c.Channel), map[string][]string{ + "package_ids": c.References, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + references, err = conan_model.GetPackageReferences(db.DefaultContext, user.ID, rref) + require.NoError(t, err) + assert.Empty(t, references, "case %d: should be empty", i) + } + }) + + t.Run("Recipe", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + cases := []struct { + Channel string + }{ + {channel1}, + {channel2}, + } + + for i, c := range cases { + rref, _ := conan_module.NewRecipeReference(name, version1, user1, c.Channel, conan_module.DefaultRevision) + revisions, err := conan_model.GetRecipeRevisions(db.DefaultContext, user.ID, rref) + require.NoError(t, err) + assert.NotEmpty(t, revisions) + + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/v1/conans/%s/%s/%s/%s", url, name, version1, user1, c.Channel)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + revisions, err = conan_model.GetRecipeRevisions(db.DefaultContext, user.ID, rref) + require.NoError(t, err) + assert.Empty(t, revisions, "case %d: should be empty", i) + } + }) + }) + }) + + t.Run("v2", func(t *testing.T) { + t.Run("Ping", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/v2/ping", url)) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "revisions", resp.Header().Get("X-Conan-Server-Capabilities")) + }) + + token := "" + + t.Run("Token Scope Authentication", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, user.Name) + + testCase := func(t *testing.T, scope auth_model.AccessTokenScope, expectedStatusCode int) { + t.Helper() + + token := getTokenForLoggedInUser(t, session, scope) + + req := NewRequest(t, "GET", fmt.Sprintf("%s/v2/users/authenticate", url)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + body := resp.Body.String() + assert.NotEmpty(t, body) + + recipeURL := fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions/%s", url, "TestScope", version1, "testing", channel1, revision1) + + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/files/%s", recipeURL, conanfileName), strings.NewReader("Doesn't need to be valid")). + AddTokenAuth("Bearer " + body) + MakeRequest(t, req, expectedStatusCode) + } + + t.Run("Read permission", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testCase(t, auth_model.AccessTokenScopeReadPackage, http.StatusUnauthorized) + }) + + t.Run("Write permission", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testCase(t, auth_model.AccessTokenScopeWritePackage, http.StatusCreated) + }) + }) + + t.Run("Authenticate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/v2/users/authenticate", url)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + body := resp.Body.String() + assert.NotEmpty(t, body) + + token = fmt.Sprintf("Bearer %s", body) + }) + + t.Run("CheckCredentials", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/v2/users/check_credentials", url)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadConanPackageV2(t, url, token, name, version1, user1, channel1, revision1, revision1) + + t.Run("Validate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeConan) + require.NoError(t, err) + assert.Len(t, pvs, 3) + }) + }) + + t.Run("Latest", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + recipeURL := fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s", url, name, version1, user1, channel1) + + req := NewRequest(t, "GET", fmt.Sprintf("%s/latest", recipeURL)) + resp := MakeRequest(t, req, http.StatusOK) + + obj := make(map[string]string) + DecodeJSON(t, resp, &obj) + assert.Contains(t, obj, "revision") + assert.Equal(t, revision1, obj["revision"]) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/revisions/%s/packages/%s/latest", recipeURL, revision1, conanPackageReference)) + resp = MakeRequest(t, req, http.StatusOK) + + obj = make(map[string]string) + DecodeJSON(t, resp, &obj) + assert.Contains(t, obj, "revision") + assert.Equal(t, revision1, obj["revision"]) + }) + + t.Run("ListRevisions", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadConanPackageV2(t, url, token, name, version1, user1, channel1, revision1, revision2) + uploadConanPackageV2(t, url, token, name, version1, user1, channel1, revision2, revision1) + uploadConanPackageV2(t, url, token, name, version1, user1, channel1, revision2, revision2) + + recipeURL := fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions", url, name, version1, user1, channel1) + + req := NewRequest(t, "GET", recipeURL) + resp := MakeRequest(t, req, http.StatusOK) + + type RevisionInfo struct { + Revision string `json:"revision"` + Time time.Time `json:"time"` + } + + type RevisionList struct { + Revisions []*RevisionInfo `json:"revisions"` + } + + var list *RevisionList + DecodeJSON(t, resp, &list) + assert.Len(t, list.Revisions, 2) + revs := make([]string, 0, len(list.Revisions)) + for _, rev := range list.Revisions { + revs = append(revs, rev.Revision) + } + assert.ElementsMatch(t, []string{revision1, revision2}, revs) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/packages/%s/revisions", recipeURL, revision1, conanPackageReference)) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &list) + assert.Len(t, list.Revisions, 2) + revs = make([]string, 0, len(list.Revisions)) + for _, rev := range list.Revisions { + revs = append(revs, rev.Revision) + } + assert.ElementsMatch(t, []string{revision1, revision2}, revs) + }) + + t.Run("Search", func(t *testing.T) { + t.Run("Recipe", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + cases := []struct { + Query string + Expected []string + }{ + {"ConanPackage", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/1.2", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/1.1", []string{}}, + {"Conan*", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/*", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/1*", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/*2", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/1*2", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/1.2@", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"ConanPackage/1.2@du*", []string{"ConanPackage/1.2@dummy/test"}}, + {"ConanPackage/1.2@du*/", []string{"ConanPackage/1.2@dummy/test"}}, + {"ConanPackage/1.2@du*/*test", []string{"ConanPackage/1.2@dummy/test"}}, + {"ConanPackage/1.2@du*/*st", []string{"ConanPackage/1.2@dummy/test"}}, + {"ConanPackage/1.2@gitea/*", []string{"ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}}, + {"*/*@dummy", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test"}}, + {"*/*@*/final", []string{"ConanPackage/1.2@gitea/final"}}, + } + + for i, c := range cases { + req := NewRequest(t, "GET", fmt.Sprintf("%s/v2/conans/search?q=%s", url, stdurl.QueryEscape(c.Query))) + resp := MakeRequest(t, req, http.StatusOK) + + var result *conan_router.SearchResult + DecodeJSON(t, resp, &result) + + assert.ElementsMatch(t, c.Expected, result.Results, "case %d: unexpected result", i) + } + }) + + t.Run("Package", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/search", url, name, version1, user1, channel1)) + resp := MakeRequest(t, req, http.StatusOK) + + var result map[string]*conan_module.Conaninfo + DecodeJSON(t, resp, &result) + + assert.Contains(t, result, conanPackageReference) + info := result[conanPackageReference] + assert.NotEmpty(t, info.Settings) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions/%s/search", url, name, version1, user1, channel1, revision1)) + resp = MakeRequest(t, req, http.StatusOK) + + result = make(map[string]*conan_module.Conaninfo) + DecodeJSON(t, resp, &result) + + assert.Contains(t, result, conanPackageReference) + info = result[conanPackageReference] + assert.NotEmpty(t, info.Settings) + }) + }) + + t.Run("Delete", func(t *testing.T) { + t.Run("Package", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + rref, _ := conan_module.NewRecipeReference(name, version1, user1, channel1, revision1) + pref, _ := conan_module.NewPackageReference(rref, conanPackageReference, conan_module.DefaultRevision) + + checkPackageRevisionCount := func(count int) { + revisions, err := conan_model.GetPackageRevisions(db.DefaultContext, user.ID, pref) + require.NoError(t, err) + assert.Len(t, revisions, count) + } + checkPackageReferenceCount := func(count int) { + references, err := conan_model.GetPackageReferences(db.DefaultContext, user.ID, rref) + require.NoError(t, err) + assert.Len(t, references, count) + } + + checkPackageRevisionCount(2) + + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions/%s/packages/%s/revisions/%s", url, name, version1, user1, channel1, revision1, conanPackageReference, revision1)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + checkPackageRevisionCount(1) + + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions/%s/packages/%s", url, name, version1, user1, channel1, revision1, conanPackageReference)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + checkPackageRevisionCount(0) + + rref = rref.WithRevision(revision2) + + checkPackageReferenceCount(1) + + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions/%s/packages", url, name, version1, user1, channel1, revision2)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + checkPackageReferenceCount(0) + }) + + t.Run("Recipe", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + rref, _ := conan_module.NewRecipeReference(name, version1, user1, channel1, conan_module.DefaultRevision) + + checkRecipeRevisionCount := func(count int) { + revisions, err := conan_model.GetRecipeRevisions(db.DefaultContext, user.ID, rref) + require.NoError(t, err) + assert.Len(t, revisions, count) + } + + checkRecipeRevisionCount(2) + + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions/%s", url, name, version1, user1, channel1, revision1)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + checkRecipeRevisionCount(1) + + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s", url, name, version1, user1, channel1)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + checkRecipeRevisionCount(0) + }) + }) + }) +} diff --git a/tests/integration/api_packages_conda_test.go b/tests/integration/api_packages_conda_test.go new file mode 100644 index 0000000..4625c58 --- /dev/null +++ b/tests/integration/api_packages_conda_test.go @@ -0,0 +1,275 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/tar" + "archive/zip" + "bytes" + "fmt" + "io" + "net/http" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + conda_module "code.gitea.io/gitea/modules/packages/conda" + "code.gitea.io/gitea/modules/zstd" + "code.gitea.io/gitea/tests" + + "github.com/dsnet/compress/bzip2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageConda(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + packageName := "test_package" + packageVersion := "1.0.1" + + channel := "test-channel" + root := fmt.Sprintf("/api/packages/%s/conda", user.Name) + + t.Run("Upload", func(t *testing.T) { + tarContent := func() []byte { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + + content := []byte(`{"name":"` + packageName + `","version":"` + packageVersion + `","subdir":"noarch","build":"xxx"}`) + + hdr := &tar.Header{ + Name: "info/index.json", + Mode: 0o600, + Size: int64(len(content)), + } + tw.WriteHeader(hdr) + tw.Write(content) + tw.Close() + return buf.Bytes() + }() + + t.Run(".tar.bz2", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + var buf bytes.Buffer + bw, _ := bzip2.NewWriter(&buf, nil) + io.Copy(bw, bytes.NewReader(tarContent)) + bw.Close() + + filename := fmt.Sprintf("%s-%s.tar.bz2", packageName, packageVersion) + + req := NewRequestWithBody(t, "PUT", root+"/"+filename, bytes.NewReader(buf.Bytes())) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", root+"/"+filename, bytes.NewReader(buf.Bytes())). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequestWithBody(t, "PUT", root+"/"+filename, bytes.NewReader(buf.Bytes())). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusConflict) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeConda) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.Nil(t, pd.SemVer) + assert.IsType(t, &conda_module.VersionMetadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + assert.Empty(t, pd.PackageProperties.GetByName(conda_module.PropertyChannel)) + }) + + t.Run(".conda", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + var infoBuf bytes.Buffer + zsw, _ := zstd.NewWriter(&infoBuf) + io.Copy(zsw, bytes.NewReader(tarContent)) + zsw.Close() + + var buf bytes.Buffer + zpw := zip.NewWriter(&buf) + w, _ := zpw.Create("info-x.tar.zst") + w.Write(infoBuf.Bytes()) + zpw.Close() + + fullName := channel + "/" + packageName + filename := fmt.Sprintf("%s-%s.conda", packageName, packageVersion) + + req := NewRequestWithBody(t, "PUT", root+"/"+channel+"/"+filename, bytes.NewReader(buf.Bytes())) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", root+"/"+channel+"/"+filename, bytes.NewReader(buf.Bytes())). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequestWithBody(t, "PUT", root+"/"+channel+"/"+filename, bytes.NewReader(buf.Bytes())). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusConflict) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeConda) + require.NoError(t, err) + assert.Len(t, pvs, 2) + + pds, err := packages.GetPackageDescriptors(db.DefaultContext, pvs) + require.NoError(t, err) + + assert.Condition(t, func() bool { + for _, pd := range pds { + if pd.Package.Name == fullName { + return true + } + } + return false + }) + + for _, pd := range pds { + if pd.Package.Name == fullName { + assert.Nil(t, pd.SemVer) + assert.IsType(t, &conda_module.VersionMetadata{}, pd.Metadata) + assert.Equal(t, fullName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + assert.Equal(t, channel, pd.PackageProperties.GetByName(conda_module.PropertyChannel)) + } + } + }) + }) + + t.Run("Download", func(t *testing.T) { + t.Run(".tar.bz2", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/noarch/%s-%s-xxx.tar.bz2", root, packageName, packageVersion)) + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/noarch/%s-%s-xxx.tar.bz2", root, channel, packageName, packageVersion)) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run(".conda", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/noarch/%s-%s-xxx.conda", root, packageName, packageVersion)) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/noarch/%s-%s-xxx.conda", root, channel, packageName, packageVersion)) + MakeRequest(t, req, http.StatusOK) + }) + }) + + t.Run("EnumeratePackages", func(t *testing.T) { + type Info struct { + Subdir string `json:"subdir"` + } + + type PackageInfo struct { + Name string `json:"name"` + Version string `json:"version"` + NoArch string `json:"noarch"` + Subdir string `json:"subdir"` + Timestamp int64 `json:"timestamp"` + Build string `json:"build"` + BuildNumber int64 `json:"build_number"` + Dependencies []string `json:"depends"` + License string `json:"license"` + LicenseFamily string `json:"license_family"` + HashMD5 string `json:"md5"` + HashSHA256 string `json:"sha256"` + Size int64 `json:"size"` + } + + type RepoData struct { + Info Info `json:"info"` + Packages map[string]*PackageInfo `json:"packages"` + PackagesConda map[string]*PackageInfo `json:"packages.conda"` + Removed map[string]*PackageInfo `json:"removed"` + } + + req := NewRequest(t, "GET", fmt.Sprintf("%s/noarch/repodata.json", root)) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "application/json", resp.Header().Get("Content-Type")) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/noarch/repodata.json.bz2", root)) + resp = MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "application/x-bzip2", resp.Header().Get("Content-Type")) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/noarch/current_repodata.json", root)) + resp = MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "application/json", resp.Header().Get("Content-Type")) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/noarch/current_repodata.json.bz2", root)) + resp = MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "application/x-bzip2", resp.Header().Get("Content-Type")) + + t.Run(".tar.bz2", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeConda, packageName, packageVersion) + require.NoError(t, err) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pv) + require.NoError(t, err) + + req := NewRequest(t, "GET", fmt.Sprintf("%s/noarch/repodata.json", root)) + resp := MakeRequest(t, req, http.StatusOK) + + var result RepoData + DecodeJSON(t, resp, &result) + + assert.Equal(t, "noarch", result.Info.Subdir) + assert.Empty(t, result.PackagesConda) + assert.Empty(t, result.Removed) + + filename := fmt.Sprintf("%s-%s-xxx.tar.bz2", packageName, packageVersion) + assert.Contains(t, result.Packages, filename) + packageInfo := result.Packages[filename] + assert.Equal(t, packageName, packageInfo.Name) + assert.Equal(t, packageVersion, packageInfo.Version) + assert.Equal(t, "noarch", packageInfo.Subdir) + assert.Equal(t, "xxx", packageInfo.Build) + assert.Equal(t, pd.Files[0].Blob.HashMD5, packageInfo.HashMD5) + assert.Equal(t, pd.Files[0].Blob.HashSHA256, packageInfo.HashSHA256) + assert.Equal(t, pd.Files[0].Blob.Size, packageInfo.Size) + }) + + t.Run(".conda", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeConda, channel+"/"+packageName, packageVersion) + require.NoError(t, err) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pv) + require.NoError(t, err) + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/noarch/repodata.json", root, channel)) + resp := MakeRequest(t, req, http.StatusOK) + + var result RepoData + DecodeJSON(t, resp, &result) + + assert.Equal(t, "noarch", result.Info.Subdir) + assert.Empty(t, result.Packages) + assert.Empty(t, result.Removed) + + filename := fmt.Sprintf("%s-%s-xxx.conda", packageName, packageVersion) + assert.Contains(t, result.PackagesConda, filename) + packageInfo := result.PackagesConda[filename] + assert.Equal(t, packageName, packageInfo.Name) + assert.Equal(t, packageVersion, packageInfo.Version) + assert.Equal(t, "noarch", packageInfo.Subdir) + assert.Equal(t, "xxx", packageInfo.Build) + assert.Equal(t, pd.Files[0].Blob.HashMD5, packageInfo.HashMD5) + assert.Equal(t, pd.Files[0].Blob.HashSHA256, packageInfo.HashSHA256) + assert.Equal(t, pd.Files[0].Blob.Size, packageInfo.Size) + }) + }) +} diff --git a/tests/integration/api_packages_container_cleanup_sha256_test.go b/tests/integration/api_packages_container_cleanup_sha256_test.go new file mode 100644 index 0000000..eb63eff --- /dev/null +++ b/tests/integration/api_packages_container_cleanup_sha256_test.go @@ -0,0 +1,238 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package integration + +import ( + "bytes" + "encoding/base64" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + packages_module "code.gitea.io/gitea/modules/packages" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + packages_cleanup "code.gitea.io/gitea/services/packages/cleanup" + packages_container "code.gitea.io/gitea/services/packages/container" + "code.gitea.io/gitea/tests" + + oci "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackagesContainerCleanupSHA256(t *testing.T) { + defer tests.PrepareTestEnv(t, 1)() + defer test.MockVariableValue(&setting.Packages.Storage.Type, setting.LocalStorageType)() + defer test.MockVariableValue(&packages_container.SHA256BatchSize, 1)() + + ctx := db.DefaultContext + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + cleanupAndCheckLogs := func(t *testing.T, expected ...string) { + t.Helper() + logChecker, cleanup := test.NewLogChecker(log.DEFAULT, log.TRACE) + logChecker.Filter(expected...) + logChecker.StopMark(packages_container.SHA256LogFinish) + defer cleanup() + + require.NoError(t, packages_cleanup.CleanupExpiredData(ctx, -1*time.Hour)) + + logFiltered, logStopped := logChecker.Check(5 * time.Second) + assert.True(t, logStopped) + filtered := make([]bool, 0, len(expected)) + for range expected { + filtered = append(filtered, true) + } + assert.EqualValues(t, filtered, logFiltered, expected) + } + + userToken := "" + + t.Run("Authenticate", func(t *testing.T) { + type TokenResponse struct { + Token string `json:"token"` + } + + authenticate := []string{`Bearer realm="` + setting.AppURL + `v2/token",service="container_registry",scope="*"`} + + t.Run("User", func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)) + resp := MakeRequest(t, req, http.StatusUnauthorized) + + assert.ElementsMatch(t, authenticate, resp.Header().Values("WWW-Authenticate")) + + req = NewRequest(t, "GET", fmt.Sprintf("%sv2/token", setting.AppURL)). + AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + tokenResponse := &TokenResponse{} + DecodeJSON(t, resp, &tokenResponse) + + assert.NotEmpty(t, tokenResponse.Token) + + userToken = fmt.Sprintf("Bearer %s", tokenResponse.Token) + + req = NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusOK) + }) + }) + + image := "test" + multiTag := "multi" + + url := fmt.Sprintf("%sv2/%s/%s", setting.AppURL, user.Name, image) + + blobDigest := "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4" + sha256ManifestDigest := "sha256:4305f5f5572b9a426b88909b036e52ee3cf3d7b9c1b01fac840e90747f56623d" + indexManifestDigest := "sha256:b992f98104ab25f60d78368a674ce6f6a49741f4e32729e8496067ed06174e9b" + + uploadSHA256Version := func(t *testing.T) { + t.Helper() + + blobContent, _ := base64.StdEncoding.DecodeString(`H4sIAAAJbogA/2IYBaNgFIxYAAgAAP//Lq+17wAEAAA=`) + + req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, blobDigest), bytes.NewReader(blobContent)). + AddTokenAuth(userToken) + resp := MakeRequest(t, req, http.StatusCreated) + + assert.Equal(t, fmt.Sprintf("/v2/%s/%s/blobs/%s", user.Name, image, blobDigest), resp.Header().Get("Location")) + assert.Equal(t, blobDigest, resp.Header().Get("Docker-Content-Digest")) + + configDigest := "sha256:4607e093bec406eaadb6f3a340f63400c9d3a7038680744c406903766b938f0d" + configContent := `{"architecture":"amd64","config":{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/true"],"ArgsEscaped":true,"Image":"sha256:9bd8b88dc68b80cffe126cc820e4b52c6e558eb3b37680bfee8e5f3ed7b8c257"},"container":"b89fe92a887d55c0961f02bdfbfd8ac3ddf66167db374770d2d9e9fab3311510","container_config":{"Hostname":"b89fe92a887d","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh","-c","#(nop) ","CMD [\"/true\"]"],"ArgsEscaped":true,"Image":"sha256:9bd8b88dc68b80cffe126cc820e4b52c6e558eb3b37680bfee8e5f3ed7b8c257"},"created":"2022-01-01T00:00:00.000000000Z","docker_version":"20.10.12","history":[{"created":"2022-01-01T00:00:00.000000000Z","created_by":"/bin/sh -c #(nop) COPY file:0e7589b0c800daaf6fa460d2677101e4676dd9491980210cb345480e513f3602 in /true "},{"created":"2022-01-01T00:00:00.000000001Z","created_by":"/bin/sh -c #(nop) CMD [\"/true\"]","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:0ff3b91bdf21ecdf2f2f3d4372c2098a14dbe06cd678e8f0a85fd4902d00e2e2"]}}` + + req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, configDigest), strings.NewReader(configContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusCreated) + + sha256ManifestContent := `{"schemaVersion":2,"mediaType":"` + oci.MediaTypeImageManifest + `","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"sha256:4607e093bec406eaadb6f3a340f63400c9d3a7038680744c406903766b938f0d","size":1069},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","digest":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4","size":32}]}` + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, sha256ManifestDigest), strings.NewReader(sha256ManifestContent)). + AddTokenAuth(userToken). + SetHeader("Content-Type", oci.MediaTypeImageManifest) + resp = MakeRequest(t, req, http.StatusCreated) + + assert.Equal(t, sha256ManifestDigest, resp.Header().Get("Docker-Content-Digest")) + + req = NewRequest(t, "HEAD", fmt.Sprintf("%s/manifests/%s", url, sha256ManifestDigest)). + AddTokenAuth(userToken) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, fmt.Sprintf("%d", len(sha256ManifestContent)), resp.Header().Get("Content-Length")) + assert.Equal(t, sha256ManifestDigest, resp.Header().Get("Docker-Content-Digest")) + } + + uploadIndexManifest := func(t *testing.T) { + indexManifestContent := `{"schemaVersion":2,"mediaType":"` + oci.MediaTypeImageIndex + `","manifests":[{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"` + sha256ManifestDigest + `","platform":{"os":"linux","architecture":"arm","variant":"v7"}}]}` + req := NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, multiTag), strings.NewReader(indexManifestContent)). + AddTokenAuth(userToken). + SetHeader("Content-Type", oci.MediaTypeImageIndex) + resp := MakeRequest(t, req, http.StatusCreated) + + assert.Equal(t, indexManifestDigest, resp.Header().Get("Docker-Content-Digest")) + } + + assertImageExists := func(t *testing.T, manifestDigest, blobDigest string) { + req := NewRequest(t, "HEAD", fmt.Sprintf("%s/manifests/%s", url, manifestDigest)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusOK) + } + + assertImageNotExists := func(t *testing.T, manifestDigest, blobDigest string) { + req := NewRequest(t, "HEAD", fmt.Sprintf("%s/manifests/%s", url, manifestDigest)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusNotFound) + } + + assertImageDeleted := func(t *testing.T, image, manifestDigest, blobDigest string, cleanup func()) { + t.Helper() + packageVersion := unittest.AssertExistsAndLoadBean(t, &packages_model.PackageVersion{Version: manifestDigest}) + packageFile := unittest.AssertExistsAndLoadBean(t, &packages_model.PackageFile{VersionID: packageVersion.ID}) + unittest.AssertExistsAndLoadBean(t, &packages_model.PackageProperty{RefID: packageFile.ID, RefType: packages_model.PropertyTypeVersion}) + packageBlob := unittest.AssertExistsAndLoadBean(t, &packages_model.PackageBlob{ID: packageFile.BlobID}) + contentStore := packages_module.NewContentStore() + require.NoError(t, contentStore.Has(packages_module.BlobHash256Key(packageBlob.HashSHA256))) + + assertImageExists(t, manifestDigest, blobDigest) + + cleanup() + + assertImageNotExists(t, manifestDigest, blobDigest) + + unittest.AssertNotExistsBean(t, &packages_model.PackageVersion{Version: manifestDigest}) + unittest.AssertNotExistsBean(t, &packages_model.PackageFile{VersionID: packageVersion.ID}) + unittest.AssertNotExistsBean(t, &packages_model.PackageProperty{RefID: packageFile.ID, RefType: packages_model.PropertyTypeVersion}) + unittest.AssertNotExistsBean(t, &packages_model.PackageBlob{ID: packageFile.BlobID}) + assert.Error(t, contentStore.Has(packages_module.BlobHash256Key(packageBlob.HashSHA256))) + } + + assertImageAndPackageDeleted := func(t *testing.T, image, manifestDigest, blobDigest string, cleanup func()) { + t.Helper() + unittest.AssertExistsAndLoadBean(t, &packages_model.Package{Name: image}) + assertImageDeleted(t, image, manifestDigest, blobDigest, cleanup) + unittest.AssertNotExistsBean(t, &packages_model.Package{Name: image}) + } + + t.Run("Nothing to look at", func(t *testing.T) { + cleanupAndCheckLogs(t, "There are no container images with a version matching sha256:*") + }) + + uploadSHA256Version(t) + + t.Run("Dangling image found", func(t *testing.T) { + assertImageAndPackageDeleted(t, image, sha256ManifestDigest, blobDigest, func() { + cleanupAndCheckLogs(t, + "Removing 3 entries from `package_file` and `package_property`", + "Removing 1 entries from `package_version` and `package_property`", + ) + }) + }) + + uploadSHA256Version(t) + uploadIndexManifest(t) + + t.Run("Corrupted index manifest metadata is ignored", func(t *testing.T) { + assertImageExists(t, sha256ManifestDigest, blobDigest) + _, err := db.GetEngine(ctx).Table("package_version").Where("version = ?", multiTag).Update(&packages_model.PackageVersion{MetadataJSON: `corrupted "manifests":[{ bad`}) + require.NoError(t, err) + + // do not expect the package to be deleted because it contains + // corrupted metadata that prevents that from happening + assertImageDeleted(t, image, sha256ManifestDigest, blobDigest, func() { + cleanupAndCheckLogs(t, + "Removing 3 entries from `package_file` and `package_property`", + "Removing 1 entries from `package_version` and `package_property`", + "is not a JSON string containing valid metadata", + ) + }) + }) + + uploadSHA256Version(t) + uploadIndexManifest(t) + + t.Run("Image found but referenced", func(t *testing.T) { + assertImageExists(t, sha256ManifestDigest, blobDigest) + cleanupAndCheckLogs(t, + "All container images with a version matching sha256:* are referenced by an index manifest", + ) + assertImageExists(t, sha256ManifestDigest, blobDigest) + }) +} diff --git a/tests/integration/api_packages_container_test.go b/tests/integration/api_packages_container_test.go new file mode 100644 index 0000000..3c28f45 --- /dev/null +++ b/tests/integration/api_packages_container_test.go @@ -0,0 +1,759 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "fmt" + "net/http" + "strings" + "sync" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + container_model "code.gitea.io/gitea/models/packages/container" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + container_module "code.gitea.io/gitea/modules/packages/container" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/tests" + + oci "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageContainer(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadPackage) + privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 31}) + + has := func(l packages_model.PackagePropertyList, name string) bool { + for _, pp := range l { + if pp.Name == name { + return true + } + } + return false + } + getAllByName := func(l packages_model.PackagePropertyList, name string) []string { + values := make([]string, 0, len(l)) + for _, pp := range l { + if pp.Name == name { + values = append(values, pp.Value) + } + } + return values + } + + images := []string{"test", "te/st"} + tags := []string{"latest", "main"} + multiTag := "multi" + + unknownDigest := "sha256:0000000000000000000000000000000000000000000000000000000000000000" + + blobDigest := "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4" + blobContent, _ := base64.StdEncoding.DecodeString(`H4sIAAAJbogA/2IYBaNgFIxYAAgAAP//Lq+17wAEAAA=`) + + configDigest := "sha256:4607e093bec406eaadb6f3a340f63400c9d3a7038680744c406903766b938f0d" + configContent := `{"architecture":"amd64","config":{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/true"],"ArgsEscaped":true,"Image":"sha256:9bd8b88dc68b80cffe126cc820e4b52c6e558eb3b37680bfee8e5f3ed7b8c257"},"container":"b89fe92a887d55c0961f02bdfbfd8ac3ddf66167db374770d2d9e9fab3311510","container_config":{"Hostname":"b89fe92a887d","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh","-c","#(nop) ","CMD [\"/true\"]"],"ArgsEscaped":true,"Image":"sha256:9bd8b88dc68b80cffe126cc820e4b52c6e558eb3b37680bfee8e5f3ed7b8c257"},"created":"2022-01-01T00:00:00.000000000Z","docker_version":"20.10.12","history":[{"created":"2022-01-01T00:00:00.000000000Z","created_by":"/bin/sh -c #(nop) COPY file:0e7589b0c800daaf6fa460d2677101e4676dd9491980210cb345480e513f3602 in /true "},{"created":"2022-01-01T00:00:00.000000001Z","created_by":"/bin/sh -c #(nop) CMD [\"/true\"]","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:0ff3b91bdf21ecdf2f2f3d4372c2098a14dbe06cd678e8f0a85fd4902d00e2e2"]}}` + + manifestDigest := "sha256:4f10484d1c1bb13e3956b4de1cd42db8e0f14a75be1617b60f2de3cd59c803c6" + manifestContent := `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"sha256:4607e093bec406eaadb6f3a340f63400c9d3a7038680744c406903766b938f0d","size":1069},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","digest":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4","size":32}]}` + + untaggedManifestDigest := "sha256:4305f5f5572b9a426b88909b036e52ee3cf3d7b9c1b01fac840e90747f56623d" + untaggedManifestContent := `{"schemaVersion":2,"mediaType":"` + oci.MediaTypeImageManifest + `","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"sha256:4607e093bec406eaadb6f3a340f63400c9d3a7038680744c406903766b938f0d","size":1069},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","digest":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4","size":32}]}` + + indexManifestDigest := "sha256:bab112d6efb9e7f221995caaaa880352feb5bd8b1faf52fae8d12c113aa123ec" + indexManifestContent := `{"schemaVersion":2,"mediaType":"` + oci.MediaTypeImageIndex + `","manifests":[{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"` + manifestDigest + `","platform":{"os":"linux","architecture":"arm","variant":"v7"}},{"mediaType":"` + oci.MediaTypeImageManifest + `","digest":"` + untaggedManifestDigest + `","platform":{"os":"linux","architecture":"arm64","variant":"v8"}}]}` + + anonymousToken := "" + readUserToken := "" + userToken := "" + + t.Run("Authenticate", func(t *testing.T) { + type TokenResponse struct { + Token string `json:"token"` + } + + authenticate := []string{`Bearer realm="` + setting.AppURL + `v2/token",service="container_registry",scope="*"`} + + t.Run("Anonymous", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)) + resp := MakeRequest(t, req, http.StatusUnauthorized) + + assert.ElementsMatch(t, authenticate, resp.Header().Values("WWW-Authenticate")) + + req = NewRequest(t, "GET", fmt.Sprintf("%sv2/token", setting.AppURL)) + resp = MakeRequest(t, req, http.StatusOK) + + tokenResponse := &TokenResponse{} + DecodeJSON(t, resp, &tokenResponse) + + assert.NotEmpty(t, tokenResponse.Token) + + anonymousToken = fmt.Sprintf("Bearer %s", tokenResponse.Token) + + req = NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)). + AddTokenAuth(anonymousToken) + MakeRequest(t, req, http.StatusOK) + + defer test.MockVariableValue(&setting.Service.RequireSignInView, true)() + + req = NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "GET", fmt.Sprintf("%sv2/token", setting.AppURL)) + MakeRequest(t, req, http.StatusUnauthorized) + }) + + t.Run("User", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)) + resp := MakeRequest(t, req, http.StatusUnauthorized) + + assert.ElementsMatch(t, authenticate, resp.Header().Values("WWW-Authenticate")) + + req = NewRequest(t, "GET", fmt.Sprintf("%sv2/token", setting.AppURL)). + AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + tokenResponse := &TokenResponse{} + DecodeJSON(t, resp, &tokenResponse) + + assert.NotEmpty(t, tokenResponse.Token) + + userToken = fmt.Sprintf("Bearer %s", tokenResponse.Token) + + req = NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusOK) + + // Token that should enforce the read scope. + t.Run("Read scope", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadPackage) + + req := NewRequest(t, "GET", fmt.Sprintf("%sv2/token", setting.AppURL)) + req.SetBasicAuth(user.Name, token) + + resp := MakeRequest(t, req, http.StatusOK) + + tokenResponse := &TokenResponse{} + DecodeJSON(t, resp, &tokenResponse) + + assert.NotEmpty(t, tokenResponse.Token) + + readUserToken = fmt.Sprintf("Bearer %s", tokenResponse.Token) + + req = NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)). + AddTokenAuth(readUserToken) + MakeRequest(t, req, http.StatusOK) + }) + }) + }) + + t.Run("DetermineSupport", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)). + AddTokenAuth(userToken) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "registry/2.0", resp.Header().Get("Docker-Distribution-Api-Version")) + }) + + for _, image := range images { + t.Run(fmt.Sprintf("[Image:%s]", image), func(t *testing.T) { + url := fmt.Sprintf("%sv2/%s/%s", setting.AppURL, user.Name, image) + + t.Run("UploadBlob/Monolithic", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads", url)). + AddTokenAuth(anonymousToken) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads", url)). + AddTokenAuth(readUserToken) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, unknownDigest), bytes.NewReader(blobContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, blobDigest), bytes.NewReader(blobContent)). + AddTokenAuth(userToken) + resp := MakeRequest(t, req, http.StatusCreated) + + assert.Equal(t, fmt.Sprintf("/v2/%s/%s/blobs/%s", user.Name, image, blobDigest), resp.Header().Get("Location")) + assert.Equal(t, blobDigest, resp.Header().Get("Docker-Content-Digest")) + + pv, err := packages_model.GetInternalVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeContainer, image, container_model.UploadVersion) + require.NoError(t, err) + + pfs, err := packages_model.GetFilesByVersionID(db.DefaultContext, pv.ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + + pb, err := packages_model.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + require.NoError(t, err) + assert.EqualValues(t, len(blobContent), pb.Size) + }) + + t.Run("UploadBlob/Chunked", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads", url)). + AddTokenAuth(userToken) + resp := MakeRequest(t, req, http.StatusAccepted) + + uuid := resp.Header().Get("Docker-Upload-Uuid") + assert.NotEmpty(t, uuid) + + pbu, err := packages_model.GetBlobUploadByID(db.DefaultContext, uuid) + require.NoError(t, err) + assert.EqualValues(t, 0, pbu.BytesReceived) + + uploadURL := resp.Header().Get("Location") + assert.NotEmpty(t, uploadURL) + + req = NewRequestWithBody(t, "PATCH", setting.AppURL+uploadURL[1:]+"000", bytes.NewReader(blobContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequestWithBody(t, "PATCH", setting.AppURL+uploadURL[1:], bytes.NewReader(blobContent)). + AddTokenAuth(userToken). + SetHeader("Content-Range", "1-10") + MakeRequest(t, req, http.StatusRequestedRangeNotSatisfiable) + + contentRange := fmt.Sprintf("0-%d", len(blobContent)-1) + req.SetHeader("Content-Range", contentRange) + resp = MakeRequest(t, req, http.StatusAccepted) + + assert.Equal(t, uuid, resp.Header().Get("Docker-Upload-Uuid")) + assert.Equal(t, contentRange, resp.Header().Get("Range")) + + uploadURL = resp.Header().Get("Location") + + req = NewRequest(t, "GET", setting.AppURL+uploadURL[1:]). + AddTokenAuth(userToken) + resp = MakeRequest(t, req, http.StatusNoContent) + + assert.Equal(t, uuid, resp.Header().Get("Docker-Upload-Uuid")) + assert.Equal(t, fmt.Sprintf("0-%d", len(blobContent)), resp.Header().Get("Range")) + + pbu, err = packages_model.GetBlobUploadByID(db.DefaultContext, uuid) + require.NoError(t, err) + assert.EqualValues(t, len(blobContent), pbu.BytesReceived) + + req = NewRequest(t, "PUT", fmt.Sprintf("%s?digest=%s", setting.AppURL+uploadURL[1:], blobDigest)). + AddTokenAuth(userToken) + resp = MakeRequest(t, req, http.StatusCreated) + + assert.Equal(t, fmt.Sprintf("/v2/%s/%s/blobs/%s", user.Name, image, blobDigest), resp.Header().Get("Location")) + assert.Equal(t, blobDigest, resp.Header().Get("Docker-Content-Digest")) + + t.Run("Cancel", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads", url)). + AddTokenAuth(userToken) + resp := MakeRequest(t, req, http.StatusAccepted) + + uuid := resp.Header().Get("Docker-Upload-Uuid") + assert.NotEmpty(t, uuid) + + uploadURL := resp.Header().Get("Location") + assert.NotEmpty(t, uploadURL) + + req = NewRequest(t, "GET", setting.AppURL+uploadURL[1:]). + AddTokenAuth(userToken) + resp = MakeRequest(t, req, http.StatusNoContent) + + assert.Equal(t, uuid, resp.Header().Get("Docker-Upload-Uuid")) + assert.Equal(t, "0-0", resp.Header().Get("Range")) + + req = NewRequest(t, "DELETE", setting.AppURL+uploadURL[1:]). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "GET", setting.AppURL+uploadURL[1:]). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusNotFound) + }) + }) + + t.Run("UploadBlob/Mount", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + privateBlobDigest := "sha256:6ccce4863b70f258d691f59609d31b4502e1ba5199942d3bc5d35d17a4ce771d" + req := NewRequestWithBody(t, "POST", fmt.Sprintf("%sv2/%s/%s/blobs/uploads?digest=%s", setting.AppURL, privateUser.Name, image, privateBlobDigest), strings.NewReader("gitea")). + AddBasicAuth(privateUser.Name) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads?mount=%s", url, unknownDigest)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusAccepted) + + req = NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads?mount=%s", url, privateBlobDigest)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusAccepted) + + req = NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads?mount=%s", url, blobDigest)). + AddTokenAuth(userToken) + resp := MakeRequest(t, req, http.StatusCreated) + + assert.Equal(t, fmt.Sprintf("/v2/%s/%s/blobs/%s", user.Name, image, blobDigest), resp.Header().Get("Location")) + assert.Equal(t, blobDigest, resp.Header().Get("Docker-Content-Digest")) + + req = NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads?mount=%s&from=%s", url, unknownDigest, "unknown/image")). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusAccepted) + + req = NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads?mount=%s&from=%s/%s", url, blobDigest, user.Name, image)). + AddTokenAuth(userToken) + resp = MakeRequest(t, req, http.StatusCreated) + + assert.Equal(t, fmt.Sprintf("/v2/%s/%s/blobs/%s", user.Name, image, blobDigest), resp.Header().Get("Location")) + assert.Equal(t, blobDigest, resp.Header().Get("Docker-Content-Digest")) + }) + + for _, tag := range tags { + t.Run(fmt.Sprintf("[Tag:%s]", tag), func(t *testing.T) { + t.Run("UploadManifest", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, configDigest), strings.NewReader(configContent)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, tag), strings.NewReader(manifestContent)). + AddTokenAuth(anonymousToken). + SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, tag), strings.NewReader(manifestContent)). + AddTokenAuth(readUserToken). + SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, tag), strings.NewReader(manifestContent)). + AddTokenAuth(userToken). + SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") + resp := MakeRequest(t, req, http.StatusCreated) + + assert.Equal(t, manifestDigest, resp.Header().Get("Docker-Content-Digest")) + + pv, err := packages_model.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeContainer, image, tag) + require.NoError(t, err) + + pd, err := packages_model.GetPackageDescriptor(db.DefaultContext, pv) + require.NoError(t, err) + assert.Nil(t, pd.SemVer) + assert.Equal(t, image, pd.Package.Name) + assert.Equal(t, tag, pd.Version.Version) + assert.ElementsMatch(t, []string{strings.ToLower(user.LowerName + "/" + image)}, getAllByName(pd.PackageProperties, container_module.PropertyRepository)) + assert.True(t, has(pd.VersionProperties, container_module.PropertyManifestTagged)) + + assert.IsType(t, &container_module.Metadata{}, pd.Metadata) + metadata := pd.Metadata.(*container_module.Metadata) + assert.Equal(t, container_module.TypeOCI, metadata.Type) + assert.Len(t, metadata.ImageLayers, 2) + assert.Empty(t, metadata.Manifests) + + assert.Len(t, pd.Files, 3) + for _, pfd := range pd.Files { + switch pfd.File.Name { + case container_model.ManifestFilename: + assert.True(t, pfd.File.IsLead) + assert.Equal(t, "application/vnd.docker.distribution.manifest.v2+json", pfd.Properties.GetByName(container_module.PropertyMediaType)) + assert.Equal(t, manifestDigest, pfd.Properties.GetByName(container_module.PropertyDigest)) + case strings.Replace(configDigest, ":", "_", 1): + assert.False(t, pfd.File.IsLead) + assert.Equal(t, "application/vnd.docker.container.image.v1+json", pfd.Properties.GetByName(container_module.PropertyMediaType)) + assert.Equal(t, configDigest, pfd.Properties.GetByName(container_module.PropertyDigest)) + case strings.Replace(blobDigest, ":", "_", 1): + assert.False(t, pfd.File.IsLead) + assert.Equal(t, "application/vnd.docker.image.rootfs.diff.tar.gzip", pfd.Properties.GetByName(container_module.PropertyMediaType)) + assert.Equal(t, blobDigest, pfd.Properties.GetByName(container_module.PropertyDigest)) + default: + assert.FailNow(t, "unknown file: %s", pfd.File.Name) + } + } + + req = NewRequest(t, "GET", fmt.Sprintf("%s/manifests/%s", url, tag)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusOK) + + pv, err = packages_model.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeContainer, image, tag) + require.NoError(t, err) + assert.EqualValues(t, 1, pv.DownloadCount) + + // Overwrite existing tag should keep the download count + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, tag), strings.NewReader(manifestContent)). + AddTokenAuth(userToken). + SetHeader("Content-Type", oci.MediaTypeImageManifest) + MakeRequest(t, req, http.StatusCreated) + + pv, err = packages_model.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeContainer, image, tag) + require.NoError(t, err) + assert.EqualValues(t, 1, pv.DownloadCount) + }) + + t.Run("HeadManifest", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "HEAD", fmt.Sprintf("%s/manifests/unknown-tag", url)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "HEAD", fmt.Sprintf("%s/manifests/%s", url, tag)). + AddTokenAuth(userToken) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, fmt.Sprintf("%d", len(manifestContent)), resp.Header().Get("Content-Length")) + assert.Equal(t, manifestDigest, resp.Header().Get("Docker-Content-Digest")) + }) + + t.Run("GetManifest", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/manifests/unknown-tag", url)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/manifests/%s", url, tag)). + AddTokenAuth(userToken) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, fmt.Sprintf("%d", len(manifestContent)), resp.Header().Get("Content-Length")) + assert.Equal(t, oci.MediaTypeImageManifest, resp.Header().Get("Content-Type")) + assert.Equal(t, manifestDigest, resp.Header().Get("Docker-Content-Digest")) + assert.Equal(t, manifestContent, resp.Body.String()) + }) + }) + } + + t.Run("UploadUntaggedManifest", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, untaggedManifestDigest), strings.NewReader(untaggedManifestContent)). + AddTokenAuth(userToken). + SetHeader("Content-Type", oci.MediaTypeImageManifest) + resp := MakeRequest(t, req, http.StatusCreated) + + assert.Equal(t, untaggedManifestDigest, resp.Header().Get("Docker-Content-Digest")) + + req = NewRequest(t, "HEAD", fmt.Sprintf("%s/manifests/%s", url, untaggedManifestDigest)). + AddTokenAuth(userToken) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, fmt.Sprintf("%d", len(untaggedManifestContent)), resp.Header().Get("Content-Length")) + assert.Equal(t, untaggedManifestDigest, resp.Header().Get("Docker-Content-Digest")) + + pv, err := packages_model.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeContainer, image, untaggedManifestDigest) + require.NoError(t, err) + + pd, err := packages_model.GetPackageDescriptor(db.DefaultContext, pv) + require.NoError(t, err) + assert.Nil(t, pd.SemVer) + assert.Equal(t, image, pd.Package.Name) + assert.Equal(t, untaggedManifestDigest, pd.Version.Version) + assert.ElementsMatch(t, []string{strings.ToLower(user.LowerName + "/" + image)}, getAllByName(pd.PackageProperties, container_module.PropertyRepository)) + assert.False(t, has(pd.VersionProperties, container_module.PropertyManifestTagged)) + + assert.IsType(t, &container_module.Metadata{}, pd.Metadata) + + assert.Len(t, pd.Files, 3) + for _, pfd := range pd.Files { + if pfd.File.Name == container_model.ManifestFilename { + assert.True(t, pfd.File.IsLead) + assert.Equal(t, oci.MediaTypeImageManifest, pfd.Properties.GetByName(container_module.PropertyMediaType)) + assert.Equal(t, untaggedManifestDigest, pfd.Properties.GetByName(container_module.PropertyDigest)) + } + } + }) + + t.Run("UploadIndexManifest", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, multiTag), strings.NewReader(indexManifestContent)). + AddTokenAuth(userToken). + SetHeader("Content-Type", oci.MediaTypeImageIndex) + resp := MakeRequest(t, req, http.StatusCreated) + + assert.Equal(t, indexManifestDigest, resp.Header().Get("Docker-Content-Digest")) + + pv, err := packages_model.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeContainer, image, multiTag) + require.NoError(t, err) + + pd, err := packages_model.GetPackageDescriptor(db.DefaultContext, pv) + require.NoError(t, err) + assert.Nil(t, pd.SemVer) + assert.Equal(t, image, pd.Package.Name) + assert.Equal(t, multiTag, pd.Version.Version) + assert.ElementsMatch(t, []string{strings.ToLower(user.LowerName + "/" + image)}, getAllByName(pd.PackageProperties, container_module.PropertyRepository)) + assert.True(t, has(pd.VersionProperties, container_module.PropertyManifestTagged)) + + assert.ElementsMatch(t, []string{manifestDigest, untaggedManifestDigest}, getAllByName(pd.VersionProperties, container_module.PropertyManifestReference)) + + assert.IsType(t, &container_module.Metadata{}, pd.Metadata) + metadata := pd.Metadata.(*container_module.Metadata) + assert.Equal(t, container_module.TypeOCI, metadata.Type) + assert.Len(t, metadata.Manifests, 2) + assert.Condition(t, func() bool { + for _, m := range metadata.Manifests { + switch m.Platform { + case "linux/arm/v7": + assert.Equal(t, manifestDigest, m.Digest) + assert.EqualValues(t, 1524, m.Size) + case "linux/arm64/v8": + assert.Equal(t, untaggedManifestDigest, m.Digest) + assert.EqualValues(t, 1514, m.Size) + default: + return false + } + } + return true + }) + + assert.Len(t, pd.Files, 1) + assert.True(t, pd.Files[0].File.IsLead) + assert.Equal(t, oci.MediaTypeImageIndex, pd.Files[0].Properties.GetByName(container_module.PropertyMediaType)) + assert.Equal(t, indexManifestDigest, pd.Files[0].Properties.GetByName(container_module.PropertyDigest)) + }) + + t.Run("HeadBlob", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, unknownDigest)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)). + AddTokenAuth(userToken) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, fmt.Sprintf("%d", len(blobContent)), resp.Header().Get("Content-Length")) + assert.Equal(t, blobDigest, resp.Header().Get("Docker-Content-Digest")) + + req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)). + AddTokenAuth(anonymousToken) + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)). + AddTokenAuth(readUserToken) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("GetBlob", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/blobs/%s", url, unknownDigest)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/blobs/%s", url, blobDigest)). + AddTokenAuth(userToken) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, fmt.Sprintf("%d", len(blobContent)), resp.Header().Get("Content-Length")) + assert.Equal(t, blobDigest, resp.Header().Get("Docker-Content-Digest")) + assert.Equal(t, blobContent, resp.Body.Bytes()) + }) + + t.Run("GetTagList", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + cases := []struct { + URL string + ExpectedTags []string + ExpectedLink string + }{ + { + URL: fmt.Sprintf("%s/tags/list", url), + ExpectedTags: []string{"latest", "main", "multi"}, + ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=multi>; rel="next"`, user.Name, image), + }, + { + URL: fmt.Sprintf("%s/tags/list?n=0", url), + ExpectedTags: []string{}, + ExpectedLink: "", + }, + { + URL: fmt.Sprintf("%s/tags/list?n=2", url), + ExpectedTags: []string{"latest", "main"}, + ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=main&n=2>; rel="next"`, user.Name, image), + }, + { + URL: fmt.Sprintf("%s/tags/list?last=main", url), + ExpectedTags: []string{"multi"}, + ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=multi>; rel="next"`, user.Name, image), + }, + { + URL: fmt.Sprintf("%s/tags/list?n=1&last=latest", url), + ExpectedTags: []string{"main"}, + ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=main&n=1>; rel="next"`, user.Name, image), + }, + } + + for _, c := range cases { + req := NewRequest(t, "GET", c.URL). + AddTokenAuth(userToken) + resp := MakeRequest(t, req, http.StatusOK) + + type TagList struct { + Name string `json:"name"` + Tags []string `json:"tags"` + } + + tagList := &TagList{} + DecodeJSON(t, resp, &tagList) + + assert.Equal(t, user.Name+"/"+image, tagList.Name) + assert.Equal(t, c.ExpectedTags, tagList.Tags) + assert.Equal(t, c.ExpectedLink, resp.Header().Get("Link")) + } + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s?type=container&q=%s", user.Name, image)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var apiPackages []*api.Package + DecodeJSON(t, resp, &apiPackages) + assert.Len(t, apiPackages, 4) // "latest", "main", "multi", "sha256:..." + }) + + t.Run("Delete", func(t *testing.T) { + t.Run("Blob", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/blobs/%s", url, blobDigest)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusAccepted) + + req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("ManifestByDigest", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/manifests/%s", url, untaggedManifestDigest)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusAccepted) + + req = NewRequest(t, "HEAD", fmt.Sprintf("%s/manifests/%s", url, untaggedManifestDigest)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("ManifestByTag", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/manifests/%s", url, multiTag)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusAccepted) + + req = NewRequest(t, "HEAD", fmt.Sprintf("%s/manifests/%s", url, multiTag)). + AddTokenAuth(userToken) + MakeRequest(t, req, http.StatusNotFound) + }) + }) + }) + } + + // https://github.com/go-gitea/gitea/issues/19586 + t.Run("ParallelUpload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + url := fmt.Sprintf("%sv2/%s/parallel", setting.AppURL, user.Name) + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + + content := []byte{byte(i)} + digest := fmt.Sprintf("sha256:%x", sha256.Sum256(content)) + + go func() { + defer wg.Done() + + req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, digest), bytes.NewReader(content)). + AddTokenAuth(userToken) + resp := MakeRequest(t, req, http.StatusCreated) + + assert.Equal(t, digest, resp.Header().Get("Docker-Content-Digest")) + }() + } + wg.Wait() + }) + + t.Run("OwnerNameChange", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + checkCatalog := func(owner string) func(t *testing.T) { + return func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%sv2/_catalog", setting.AppURL)). + AddTokenAuth(userToken) + resp := MakeRequest(t, req, http.StatusOK) + + type RepositoryList struct { + Repositories []string `json:"repositories"` + } + + repoList := &RepositoryList{} + DecodeJSON(t, resp, &repoList) + + assert.Len(t, repoList.Repositories, len(images)) + names := make([]string, 0, len(images)) + for _, image := range images { + names = append(names, strings.ToLower(owner+"/"+image)) + } + assert.ElementsMatch(t, names, repoList.Repositories) + } + } + + t.Run(fmt.Sprintf("Catalog[%s]", user.LowerName), checkCatalog(user.LowerName)) + + session := loginUser(t, user.Name) + + newOwnerName := "newUsername" + + req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings"), + "name": newOwnerName, + "email": "user2@example.com", + "language": "en-US", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + t.Run(fmt.Sprintf("Catalog[%s]", newOwnerName), checkCatalog(newOwnerName)) + + req = NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings"), + "name": user.Name, + "email": "user2@example.com", + "language": "en-US", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + }) +} diff --git a/tests/integration/api_packages_cran_test.go b/tests/integration/api_packages_cran_test.go new file mode 100644 index 0000000..31864d1 --- /dev/null +++ b/tests/integration/api_packages_cran_test.go @@ -0,0 +1,236 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "fmt" + "net/http" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + cran_module "code.gitea.io/gitea/modules/packages/cran" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageCran(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + packageName := "test.package" + packageVersion := "1.0.3" + packageAuthor := "KN4CK3R" + packageDescription := "Gitea Test Package" + + createDescription := func(name, version string) []byte { + var buf bytes.Buffer + fmt.Fprintln(&buf, "Package:", name) + fmt.Fprintln(&buf, "Version:", version) + fmt.Fprintln(&buf, "Description:", packageDescription) + fmt.Fprintln(&buf, "Imports: abc,\n123") + fmt.Fprintln(&buf, "NeedsCompilation: yes") + fmt.Fprintln(&buf, "License: MIT") + fmt.Fprintln(&buf, "Author:", packageAuthor) + return buf.Bytes() + } + + url := fmt.Sprintf("/api/packages/%s/cran", user.Name) + + t.Run("Source", func(t *testing.T) { + createArchive := func(filename string, content []byte) *bytes.Buffer { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + hdr := &tar.Header{ + Name: filename, + Mode: 0o600, + Size: int64(len(content)), + } + tw.WriteHeader(hdr) + tw.Write(content) + tw.Close() + gw.Close() + return &buf + } + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadURL := url + "/src" + + req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", uploadURL, createArchive( + "dummy.txt", + []byte{}, + )).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequestWithBody(t, "PUT", uploadURL, createArchive( + "package/DESCRIPTION", + createDescription(packageName, packageVersion), + )).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeCran) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.Nil(t, pd.SemVer) + assert.IsType(t, &cran_module.Metadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, fmt.Sprintf("%s_%s.tar.gz", packageName, packageVersion), pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + req = NewRequestWithBody(t, "PUT", uploadURL, createArchive( + "package/DESCRIPTION", + createDescription(packageName, packageVersion), + )).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusConflict) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/src/contrib/%s_%s.tar.gz", url, packageName, packageVersion)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("Enumerate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", url+"/src/contrib/PACKAGES"). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Contains(t, resp.Header().Get("Content-Type"), "text/plain") + + body := resp.Body.String() + assert.Contains(t, body, fmt.Sprintf("Package: %s", packageName)) + assert.Contains(t, body, fmt.Sprintf("Version: %s", packageVersion)) + + req = NewRequest(t, "GET", url+"/src/contrib/PACKAGES.gz"). + AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Contains(t, resp.Header().Get("Content-Type"), "application/x-gzip") + }) + }) + + t.Run("Binary", func(t *testing.T) { + createArchive := func(filename string, content []byte) *bytes.Buffer { + var buf bytes.Buffer + archive := zip.NewWriter(&buf) + w, _ := archive.Create(filename) + w.Write(content) + archive.Close() + return &buf + } + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadURL := url + "/bin" + + req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", uploadURL, createArchive( + "dummy.txt", + []byte{}, + )).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequestWithBody(t, "PUT", uploadURL+"?platform=&rversion=", createArchive( + "package/DESCRIPTION", + createDescription(packageName, packageVersion), + )).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + + uploadURL += "?platform=windows&rversion=4.2" + + req = NewRequestWithBody(t, "PUT", uploadURL, createArchive( + "package/DESCRIPTION", + createDescription(packageName, packageVersion), + )).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeCran) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 2) + + req = NewRequestWithBody(t, "PUT", uploadURL, createArchive( + "package/DESCRIPTION", + createDescription(packageName, packageVersion), + )).AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusConflict) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + cases := []struct { + Platform string + RVersion string + ExpectedStatus int + }{ + {"osx", "4.2", http.StatusNotFound}, + {"windows", "4.1", http.StatusNotFound}, + {"windows", "4.2", http.StatusOK}, + } + + for _, c := range cases { + req := NewRequest(t, "GET", fmt.Sprintf("%s/bin/%s/contrib/%s/%s_%s.zip", url, c.Platform, c.RVersion, packageName, packageVersion)). + AddBasicAuth(user.Name) + MakeRequest(t, req, c.ExpectedStatus) + } + }) + + t.Run("Enumerate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", url+"/bin/windows/contrib/4.1/PACKAGES") + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", url+"/bin/windows/contrib/4.2/PACKAGES"). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Contains(t, resp.Header().Get("Content-Type"), "text/plain") + + body := resp.Body.String() + assert.Contains(t, body, fmt.Sprintf("Package: %s", packageName)) + assert.Contains(t, body, fmt.Sprintf("Version: %s", packageVersion)) + + req = NewRequest(t, "GET", url+"/bin/windows/contrib/4.2/PACKAGES.gz"). + AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Contains(t, resp.Header().Get("Content-Type"), "application/x-gzip") + }) + }) +} diff --git a/tests/integration/api_packages_debian_test.go b/tests/integration/api_packages_debian_test.go new file mode 100644 index 0000000..d85f56f --- /dev/null +++ b/tests/integration/api_packages_debian_test.go @@ -0,0 +1,267 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + debian_module "code.gitea.io/gitea/modules/packages/debian" + "code.gitea.io/gitea/tests" + + "github.com/blakesmith/ar" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageDebian(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + packageName := "gitea" + packageVersion := "1.0.3" + packageVersion2 := "1.0.4" + packageDescription := "Package Description" + + createArchive := func(name, version, architecture string) io.Reader { + var cbuf bytes.Buffer + zw := gzip.NewWriter(&cbuf) + tw := tar.NewWriter(zw) + tw.WriteHeader(&tar.Header{ + Name: "control", + Mode: 0o600, + Size: 50, + }) + fmt.Fprintf(tw, "Package: %s\nVersion: %s\nArchitecture: %s\nDescription: %s\n", name, version, architecture, packageDescription) + tw.Close() + zw.Close() + + var buf bytes.Buffer + aw := ar.NewWriter(&buf) + aw.WriteGlobalHeader() + hdr := &ar.Header{ + Name: "control.tar.gz", + Mode: 0o600, + Size: int64(cbuf.Len()), + } + aw.WriteHeader(hdr) + aw.Write(cbuf.Bytes()) + return &buf + } + + distributions := []string{"test", "gitea"} + components := []string{"main", "stable"} + architectures := []string{"all", "amd64"} + + rootURL := fmt.Sprintf("/api/packages/%s/debian", user.Name) + + t.Run("RepositoryKey", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", rootURL+"/repository.key") + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "application/pgp-keys", resp.Header().Get("Content-Type")) + assert.Contains(t, resp.Body.String(), "-----BEGIN PGP PUBLIC KEY BLOCK-----") + }) + + for _, distribution := range distributions { + t.Run(fmt.Sprintf("[Distribution:%s]", distribution), func(t *testing.T) { + for _, component := range components { + for _, architecture := range architectures { + t.Run(fmt.Sprintf("[Component:%s,Architecture:%s]", component, architecture), func(t *testing.T) { + uploadURL := fmt.Sprintf("%s/pool/%s/%s/upload", rootURL, distribution, component) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequestWithBody(t, "PUT", uploadURL, createArchive("", "", "")). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequestWithBody(t, "PUT", uploadURL, createArchive(packageName, packageVersion, architecture)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeDebian, packageName, packageVersion) + require.NoError(t, err) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pv) + require.NoError(t, err) + assert.Nil(t, pd.SemVer) + assert.IsType(t, &debian_module.Metadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pv.ID) + require.NoError(t, err) + assert.NotEmpty(t, pfs) + assert.Condition(t, func() bool { + seen := false + expectedFilename := fmt.Sprintf("%s_%s_%s.deb", packageName, packageVersion, architecture) + expectedCompositeKey := fmt.Sprintf("%s|%s", distribution, component) + for _, pf := range pfs { + if pf.Name == expectedFilename && pf.CompositeKey == expectedCompositeKey { + if seen { + return false + } + seen = true + + assert.True(t, pf.IsLead) + + pfps, err := packages.GetProperties(db.DefaultContext, packages.PropertyTypeFile, pf.ID) + require.NoError(t, err) + + for _, pfp := range pfps { + switch pfp.Name { + case debian_module.PropertyDistribution: + assert.Equal(t, distribution, pfp.Value) + case debian_module.PropertyComponent: + assert.Equal(t, component, pfp.Value) + case debian_module.PropertyArchitecture: + assert.Equal(t, architecture, pfp.Value) + } + } + } + } + return seen + }) + + req = NewRequestWithBody(t, "PUT", uploadURL, createArchive(packageName, packageVersion, architecture)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusConflict) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/pool/%s/%s/%s_%s_%s.deb", rootURL, distribution, component, packageName, packageVersion, architecture)) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "application/vnd.debian.binary-package", resp.Header().Get("Content-Type")) + }) + + t.Run("Packages", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", uploadURL, createArchive(packageName, packageVersion2, architecture)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + url := fmt.Sprintf("%s/dists/%s/%s/binary-%s/Packages", rootURL, distribution, component, architecture) + + req = NewRequest(t, "GET", url) + resp := MakeRequest(t, req, http.StatusOK) + + body := resp.Body.String() + + assert.Contains(t, body, "Package: "+packageName+"\n") + assert.Contains(t, body, "Version: "+packageVersion+"\n") + assert.Contains(t, body, "Version: "+packageVersion2+"\n") + assert.Contains(t, body, "Architecture: "+architecture+"\n") + assert.Contains(t, body, fmt.Sprintf("Filename: pool/%s/%s/%s_%s_%s.deb\n", distribution, component, packageName, packageVersion, architecture)) + assert.Contains(t, body, fmt.Sprintf("Filename: pool/%s/%s/%s_%s_%s.deb\n", distribution, component, packageName, packageVersion2, architecture)) + + req = NewRequest(t, "GET", url+".gz") + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", url+".xz") + MakeRequest(t, req, http.StatusOK) + + url = fmt.Sprintf("%s/dists/%s/%s/%s/by-hash/SHA256/%s", rootURL, distribution, component, architecture, base.EncodeSha256(body)) + req = NewRequest(t, "GET", url) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, body, resp.Body.String()) + }) + }) + } + } + + t.Run("Release", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/dists/%s/Release", rootURL, distribution)) + resp := MakeRequest(t, req, http.StatusOK) + + body := resp.Body.String() + + assert.Contains(t, body, "Components: "+strings.Join(components, " ")+"\n") + assert.Contains(t, body, "Architectures: "+strings.Join(architectures, " ")+"\n") + + for _, component := range components { + for _, architecture := range architectures { + assert.Contains(t, body, fmt.Sprintf("%s/binary-%s/Packages\n", component, architecture)) + assert.Contains(t, body, fmt.Sprintf("%s/binary-%s/Packages.gz\n", component, architecture)) + assert.Contains(t, body, fmt.Sprintf("%s/binary-%s/Packages.xz\n", component, architecture)) + } + } + + req = NewRequest(t, "GET", fmt.Sprintf("%s/dists/%s/by-hash/SHA256/%s", rootURL, distribution, base.EncodeSha256(body))) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, body, resp.Body.String()) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/dists/%s/Release.gpg", rootURL, distribution)) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Contains(t, resp.Body.String(), "-----BEGIN PGP SIGNATURE-----") + + req = NewRequest(t, "GET", fmt.Sprintf("%s/dists/%s/InRelease", rootURL, distribution)) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Contains(t, resp.Body.String(), "-----BEGIN PGP SIGNED MESSAGE-----") + }) + }) + } + + t.Run("Delete", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + distribution := distributions[0] + architecture := architectures[0] + + for _, component := range components { + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/pool/%s/%s/%s/%s/%s", rootURL, distribution, component, packageName, packageVersion, architecture)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/pool/%s/%s/%s/%s/%s", rootURL, distribution, component, packageName, packageVersion, architecture)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/pool/%s/%s/%s/%s/%s", rootURL, distribution, component, packageName, packageVersion2, architecture)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/dists/%s/%s/binary-%s/Packages", rootURL, distribution, component, architecture)) + MakeRequest(t, req, http.StatusNotFound) + } + + req := NewRequest(t, "GET", fmt.Sprintf("%s/dists/%s/Release", rootURL, distribution)) + resp := MakeRequest(t, req, http.StatusOK) + + body := resp.Body.String() + + assert.Contains(t, body, "Components: "+strings.Join(components, " ")+"\n") + assert.Contains(t, body, "Architectures: "+architectures[1]+"\n") + }) +} diff --git a/tests/integration/api_packages_generic_test.go b/tests/integration/api_packages_generic_test.go new file mode 100644 index 0000000..1a53f33 --- /dev/null +++ b/tests/integration/api_packages_generic_test.go @@ -0,0 +1,245 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "fmt" + "io" + "net/http" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageGeneric(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + packageName := "te-st_pac.kage" + packageVersion := "1.0.3-te st" + filename := "fi-le_na.me" + content := []byte{1, 2, 3} + + url := fmt.Sprintf("/api/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", url+"/"+filename, bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeGeneric) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.Nil(t, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, filename, pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + require.NoError(t, err) + assert.Equal(t, int64(len(content)), pb.Size) + + t.Run("Exists", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", url+"/"+filename, bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusConflict) + }) + + t.Run("Additional", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", url+"/dummy.bin", bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + // Check deduplication + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 2) + assert.Equal(t, pfs[0].BlobID, pfs[1].BlobID) + }) + + t.Run("InvalidParameter", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/packages/%s/generic/%s/%s/%s", user.Name, "invalid package name", packageVersion, filename), bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/packages/%s/generic/%s/%s/%s", user.Name, packageName, "%20test ", filename), bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/packages/%s/generic/%s/%s/%s", user.Name, packageName, packageVersion, "inva|id.name"), bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + checkDownloadCount := func(count int64) { + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeGeneric) + require.NoError(t, err) + assert.Len(t, pvs, 1) + assert.Equal(t, count, pvs[0].DownloadCount) + } + + checkDownloadCount(0) + + req := NewRequest(t, "GET", url+"/"+filename) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, content, resp.Body.Bytes()) + + checkDownloadCount(1) + + req = NewRequest(t, "GET", url+"/dummy.bin") + MakeRequest(t, req, http.StatusOK) + + checkDownloadCount(2) + + t.Run("NotExists", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", url+"/not.found") + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("RequireSignInView", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + setting.Service.RequireSignInView = true + defer func() { + setting.Service.RequireSignInView = false + }() + + req = NewRequest(t, "GET", url+"/dummy.bin") + MakeRequest(t, req, http.StatusUnauthorized) + }) + + t.Run("ServeDirect", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + if setting.Packages.Storage.Type != setting.MinioStorageType { + t.Skip("Test skipped for non-Minio-storage.") + return + } + + if !setting.Packages.Storage.MinioConfig.ServeDirect { + old := setting.Packages.Storage.MinioConfig.ServeDirect + defer func() { + setting.Packages.Storage.MinioConfig.ServeDirect = old + }() + + setting.Packages.Storage.MinioConfig.ServeDirect = true + } + + req := NewRequest(t, "GET", url+"/"+filename) + resp := MakeRequest(t, req, http.StatusSeeOther) + + checkDownloadCount(3) + + location := resp.Header().Get("Location") + assert.NotEmpty(t, location) + + resp2, err := (&http.Client{}).Get(location) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp2.StatusCode) + + body, err := io.ReadAll(resp2.Body) + require.NoError(t, err) + assert.Equal(t, content, body) + + checkDownloadCount(3) + }) + }) + + t.Run("Delete", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("File", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", url+"/"+filename) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", url+"/"+filename). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "GET", url+"/"+filename) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "DELETE", url+"/"+filename). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeGeneric) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + t.Run("RemovesVersion", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req = NewRequest(t, "DELETE", url+"/dummy.bin"). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeGeneric) + require.NoError(t, err) + assert.Empty(t, pvs) + }) + }) + + t.Run("Version", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", url+"/"+filename, bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequest(t, "DELETE", url) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", url). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeGeneric) + require.NoError(t, err) + assert.Empty(t, pvs) + + req = NewRequest(t, "GET", url+"/"+filename) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "DELETE", url). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + }) + }) +} diff --git a/tests/integration/api_packages_goproxy_test.go b/tests/integration/api_packages_goproxy_test.go new file mode 100644 index 0000000..716d90b --- /dev/null +++ b/tests/integration/api_packages_goproxy_test.go @@ -0,0 +1,167 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/zip" + "bytes" + "fmt" + "net/http" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageGo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + packageName := "gitea.com/go-gitea/gitea" + packageVersion := "v0.0.1" + packageVersion2 := "v0.0.2" + goModContent := `module "gitea.com/go-gitea/gitea"` + + createArchive := func(files map[string][]byte) []byte { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, content := range files { + w, _ := zw.Create(name) + w.Write(content) + } + zw.Close() + return buf.Bytes() + } + + url := fmt.Sprintf("/api/packages/%s/go", user.Name) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + content := createArchive(nil) + + req := NewRequestWithBody(t, "PUT", url+"/upload", bytes.NewReader(content)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", url+"/upload", bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + + content = createArchive(map[string][]byte{ + packageName + "@" + packageVersion + "/go.mod": []byte(goModContent), + }) + + req = NewRequestWithBody(t, "PUT", url+"/upload", bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeGo) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.Nil(t, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, packageVersion+".zip", pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + require.NoError(t, err) + assert.Equal(t, int64(len(content)), pb.Size) + + req = NewRequestWithBody(t, "PUT", url+"/upload", bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusConflict) + + time.Sleep(time.Second) + + content = createArchive(map[string][]byte{ + packageName + "@" + packageVersion2 + "/go.mod": []byte(goModContent), + }) + + req = NewRequestWithBody(t, "PUT", url+"/upload", bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + }) + + t.Run("List", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/@v/list", url, packageName)) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, packageVersion+"\n"+packageVersion2+"\n", resp.Body.String()) + }) + + t.Run("Info", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/@v/%s.info", url, packageName, packageVersion)) + resp := MakeRequest(t, req, http.StatusOK) + + type Info struct { + Version string `json:"Version"` + Time time.Time `json:"Time"` + } + + info := &Info{} + DecodeJSON(t, resp, &info) + + assert.Equal(t, packageVersion, info.Version) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/@v/latest.info", url, packageName)) + resp = MakeRequest(t, req, http.StatusOK) + + info = &Info{} + DecodeJSON(t, resp, &info) + + assert.Equal(t, packageVersion2, info.Version) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/@latest", url, packageName)) + resp = MakeRequest(t, req, http.StatusOK) + + info = &Info{} + DecodeJSON(t, resp, &info) + + assert.Equal(t, packageVersion2, info.Version) + }) + + t.Run("GoMod", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/@v/%s.mod", url, packageName, packageVersion)) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, goModContent, resp.Body.String()) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/@v/latest.mod", url, packageName)) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, goModContent, resp.Body.String()) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/@v/%s.zip", url, packageName, packageVersion)) + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/@v/latest.zip", url, packageName)) + MakeRequest(t, req, http.StatusOK) + }) +} diff --git a/tests/integration/api_packages_helm_test.go b/tests/integration/api_packages_helm_test.go new file mode 100644 index 0000000..4b48b74 --- /dev/null +++ b/tests/integration/api_packages_helm_test.go @@ -0,0 +1,168 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "net/http" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + helm_module "code.gitea.io/gitea/modules/packages/helm" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestPackageHelm(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + packageName := "test-chart" + packageVersion := "1.0.3" + packageAuthor := "KN4CK3R" + packageDescription := "Gitea Test Package" + + filename := fmt.Sprintf("%s-%s.tgz", packageName, packageVersion) + + chartContent := `apiVersion: v2 +description: ` + packageDescription + ` +name: ` + packageName + ` +type: application +version: ` + packageVersion + ` +maintainers: +- name: ` + packageAuthor + ` +dependencies: +- name: dep1 + repository: https://example.com/ + version: 1.0.0` + + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + archive := tar.NewWriter(zw) + archive.WriteHeader(&tar.Header{ + Name: fmt.Sprintf("%s/Chart.yaml", packageName), + Mode: 0o600, + Size: int64(len(chartContent)), + }) + archive.Write([]byte(chartContent)) + archive.Close() + zw.Close() + content := buf.Bytes() + + url := fmt.Sprintf("/api/packages/%s/helm", user.Name) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadURL := url + "/api/charts" + + req := NewRequestWithBody(t, "POST", uploadURL, bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeHelm) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.NotNil(t, pd.SemVer) + assert.IsType(t, &helm_module.Metadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, filename, pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + require.NoError(t, err) + assert.Equal(t, int64(len(content)), pb.Size) + + req = NewRequestWithBody(t, "POST", uploadURL, bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + checkDownloadCount := func(count int64) { + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeHelm) + require.NoError(t, err) + assert.Len(t, pvs, 1) + assert.Equal(t, count, pvs[0].DownloadCount) + } + + checkDownloadCount(0) + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s", url, filename)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, content, resp.Body.Bytes()) + + checkDownloadCount(1) + }) + + t.Run("Index", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/index.yaml", url)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + type ChartVersion struct { + helm_module.Metadata `yaml:",inline"` + URLs []string `yaml:"urls"` + Created time.Time `yaml:"created,omitempty"` + Removed bool `yaml:"removed,omitempty"` + Digest string `yaml:"digest,omitempty"` + } + + type ServerInfo struct { + ContextPath string `yaml:"contextPath,omitempty"` + } + + type Index struct { + APIVersion string `yaml:"apiVersion"` + Entries map[string][]*ChartVersion `yaml:"entries"` + Generated time.Time `yaml:"generated,omitempty"` + ServerInfo *ServerInfo `yaml:"serverInfo,omitempty"` + } + + var result Index + require.NoError(t, yaml.NewDecoder(resp.Body).Decode(&result)) + assert.NotEmpty(t, result.Entries) + assert.Contains(t, result.Entries, packageName) + + cvs := result.Entries[packageName] + assert.Len(t, cvs, 1) + + cv := cvs[0] + assert.Equal(t, packageName, cv.Name) + assert.Equal(t, packageVersion, cv.Version) + assert.Equal(t, packageDescription, cv.Description) + assert.Len(t, cv.Maintainers, 1) + assert.Equal(t, packageAuthor, cv.Maintainers[0].Name) + assert.Len(t, cv.Dependencies, 1) + assert.ElementsMatch(t, []string{fmt.Sprintf("%s%s/%s", setting.AppURL, url[1:], filename)}, cv.URLs) + + assert.Equal(t, url, result.ServerInfo.ContextPath) + }) +} diff --git a/tests/integration/api_packages_maven_test.go b/tests/integration/api_packages_maven_test.go new file mode 100644 index 0000000..7ada3b2 --- /dev/null +++ b/tests/integration/api_packages_maven_test.go @@ -0,0 +1,256 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/packages/maven" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageMaven(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + groupID := "com.gitea" + artifactID := "test-project" + packageName := groupID + "-" + artifactID + packageVersion := "1.0.1" + packageDescription := "Test Description" + + root := fmt.Sprintf("/api/packages/%s/maven/%s/%s", user.Name, strings.ReplaceAll(groupID, ".", "/"), artifactID) + filename := fmt.Sprintf("%s-%s.jar", packageName, packageVersion) + + putFile := func(t *testing.T, path, content string, expectedStatus int) { + req := NewRequestWithBody(t, "PUT", root+path, strings.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, expectedStatus) + } + + checkHeaders := func(t *testing.T, h http.Header, contentType string, contentLength int64) { + assert.Equal(t, contentType, h.Get("Content-Type")) + assert.Equal(t, strconv.FormatInt(contentLength, 10), h.Get("Content-Length")) + assert.NotEmpty(t, h.Get("Last-Modified")) + } + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + putFile(t, fmt.Sprintf("/%s/%s", packageVersion, filename), "test", http.StatusCreated) + putFile(t, fmt.Sprintf("/%s/%s", packageVersion, filename), "test", http.StatusConflict) + putFile(t, "/maven-metadata.xml", "test", http.StatusOK) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeMaven) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.Nil(t, pd.SemVer) + assert.Nil(t, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, filename, pfs[0].Name) + assert.False(t, pfs[0].IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + require.NoError(t, err) + assert.Equal(t, int64(4), pb.Size) + }) + + t.Run("UploadExists", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + putFile(t, fmt.Sprintf("/%s/%s", packageVersion, filename), "test", http.StatusConflict) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "HEAD", fmt.Sprintf("%s/%s/%s", root, packageVersion, filename)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + checkHeaders(t, resp.Header(), "application/java-archive", 4) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s", root, packageVersion, filename)). + AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + checkHeaders(t, resp.Header(), "application/java-archive", 4) + + assert.Equal(t, []byte("test"), resp.Body.Bytes()) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeMaven) + require.NoError(t, err) + assert.Len(t, pvs, 1) + assert.Equal(t, int64(0), pvs[0].DownloadCount) + }) + + t.Run("UploadVerifySHA1", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("Mismatch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + putFile(t, fmt.Sprintf("/%s/%s.sha1", packageVersion, filename), "test", http.StatusBadRequest) + }) + t.Run("Valid", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + putFile(t, fmt.Sprintf("/%s/%s.sha1", packageVersion, filename), "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", http.StatusOK) + }) + }) + + pomContent := `<?xml version="1.0"?> +<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <groupId>` + groupID + `</groupId> + <artifactId>` + artifactID + `</artifactId> + <version>` + packageVersion + `</version> + <description>` + packageDescription + `</description> +</project>` + + t.Run("UploadPOM", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeMaven) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.Nil(t, pd.Metadata) + + putFile(t, fmt.Sprintf("/%s/%s.pom", packageVersion, filename), pomContent, http.StatusCreated) + + pvs, err = packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeMaven) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err = packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.IsType(t, &maven.Metadata{}, pd.Metadata) + assert.Equal(t, packageDescription, pd.Metadata.(*maven.Metadata).Description) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 2) + for _, pf := range pfs { + if strings.HasSuffix(pf.Name, ".pom") { + assert.Equal(t, filename+".pom", pf.Name) + assert.True(t, pf.IsLead) + } else { + assert.False(t, pf.IsLead) + } + } + }) + + t.Run("DownloadPOM", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "HEAD", fmt.Sprintf("%s/%s/%s.pom", root, packageVersion, filename)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + checkHeaders(t, resp.Header(), "text/xml", int64(len(pomContent))) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s.pom", root, packageVersion, filename)). + AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + checkHeaders(t, resp.Header(), "text/xml", int64(len(pomContent))) + + assert.Equal(t, []byte(pomContent), resp.Body.Bytes()) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeMaven) + require.NoError(t, err) + assert.Len(t, pvs, 1) + assert.Equal(t, int64(1), pvs[0].DownloadCount) + }) + + t.Run("DownloadChecksums", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/1.2.3/%s", root, filename)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + + for key, checksum := range map[string]string{ + "md5": "098f6bcd4621d373cade4e832627b4f6", + "sha1": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", + "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + "sha512": "ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff", + } { + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s.%s", root, packageVersion, filename, key)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, checksum, resp.Body.String()) + } + }) + + t.Run("DownloadMetadata", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", root+"/maven-metadata.xml"). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + expectedMetadata := `<?xml version="1.0" encoding="UTF-8"?>` + "\n<metadata><groupId>com.gitea</groupId><artifactId>test-project</artifactId><versioning><release>1.0.1</release><latest>1.0.1</latest><versions><version>1.0.1</version></versions></versioning></metadata>" + + checkHeaders(t, resp.Header(), "text/xml", int64(len(expectedMetadata))) + + assert.Equal(t, expectedMetadata, resp.Body.String()) + + for key, checksum := range map[string]string{ + "md5": "6bee0cebaaa686d658adf3e7e16371a0", + "sha1": "8696abce499fe84d9ea93e5492abe7147e195b6c", + "sha256": "3f48322f81c4b2c3bb8649ae1e5c9801476162b520e1c2734ac06b2c06143208", + "sha512": "cb075aa2e2ef1a83cdc14dd1e08c505b72d633399b39e73a21f00f0deecb39a3e2c79f157c1163f8a3854828750706e0dec3a0f5e4778e91f8ec2cf351a855f2", + } { + req := NewRequest(t, "GET", fmt.Sprintf("%s/maven-metadata.xml.%s", root, key)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, checksum, resp.Body.String()) + } + }) + + t.Run("UploadSnapshot", func(t *testing.T) { + snapshotVersion := packageVersion + "-SNAPSHOT" + + putFile(t, fmt.Sprintf("/%s/%s", snapshotVersion, filename), "test", http.StatusCreated) + putFile(t, "/maven-metadata.xml", "test", http.StatusOK) + putFile(t, fmt.Sprintf("/%s/maven-metadata.xml", snapshotVersion), "test", http.StatusCreated) + putFile(t, fmt.Sprintf("/%s/maven-metadata.xml", snapshotVersion), "test-overwrite", http.StatusCreated) + }) + + t.Run("Partial upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + partialVersion := packageVersion + "-PARTIAL" + putFile(t, fmt.Sprintf("/%s/%s", partialVersion, filename), "test", http.StatusCreated) + pkgUIURL := fmt.Sprintf("/%s/-/packages/maven/%s-%s/%s", user.Name, groupID, artifactID, partialVersion) + req := NewRequest(t, "GET", pkgUIURL) + resp := MakeRequest(t, req, http.StatusOK) + assert.NotContains(t, resp.Body.String(), "Internal server error") + }) +} diff --git a/tests/integration/api_packages_npm_test.go b/tests/integration/api_packages_npm_test.go new file mode 100644 index 0000000..d0c54c3 --- /dev/null +++ b/tests/integration/api_packages_npm_test.go @@ -0,0 +1,332 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/url" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/packages/npm" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageNpm(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + token := fmt.Sprintf("Bearer %s", getTokenForLoggedInUser(t, loginUser(t, user.Name), auth_model.AccessTokenScopeWritePackage)) + + packageName := "@scope/test-package" + packageVersion := "1.0.1-pre" + packageTag := "latest" + packageTag2 := "release" + packageAuthor := "KN4CK3R" + packageDescription := "Test Description" + packageBinName := "cli" + packageBinPath := "./cli.sh" + repoType := "gitea" + repoURL := "http://localhost:3000/gitea/test.git" + + data := "H4sIAAAAAAAA/ytITM5OTE/VL4DQelnF+XkMVAYGBgZmJiYK2MRBwNDcSIHB2NTMwNDQzMwAqA7IMDUxA9LUdgg2UFpcklgEdAql5kD8ogCnhwio5lJQUMpLzE1VslJQcihOzi9I1S9JLS7RhSYIJR2QgrLUouLM/DyQGkM9Az1D3YIiqExKanFyUWZBCVQ2BKhVwQVJDKwosbQkI78IJO/tZ+LsbRykxFXLNdA+HwWjYBSMgpENACgAbtAACAAA" + + buildUpload := func(version string) string { + return `{ + "_id": "` + packageName + `", + "name": "` + packageName + `", + "description": "` + packageDescription + `", + "dist-tags": { + "` + packageTag + `": "` + version + `" + }, + "versions": { + "` + version + `": { + "name": "` + packageName + `", + "version": "` + version + `", + "description": "` + packageDescription + `", + "author": { + "name": "` + packageAuthor + `" + }, + "bin": { + "` + packageBinName + `": "` + packageBinPath + `" + }, + "dist": { + "integrity": "sha512-yA4FJsVhetynGfOC1jFf79BuS+jrHbm0fhh+aHzCQkOaOBXKf9oBnC4a6DnLLnEsHQDRLYd00cwj8sCXpC+wIg==", + "shasum": "aaa7eaf852a948b0aa05afeda35b1badca155d90" + }, + "repository": { + "type": "` + repoType + `", + "url": "` + repoURL + `" + } + } + }, + "_attachments": { + "` + packageName + `-` + version + `.tgz": { + "data": "` + data + `" + } + } + }` + } + + root := fmt.Sprintf("/api/packages/%s/npm/%s", user.Name, url.QueryEscape(packageName)) + tagsRoot := fmt.Sprintf("/api/packages/%s/npm/-/package/%s/dist-tags", user.Name, url.QueryEscape(packageName)) + filename := fmt.Sprintf("%s-%s.tgz", strings.Split(packageName, "/")[1], packageVersion) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", root, strings.NewReader(buildUpload(packageVersion))). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNpm) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.NotNil(t, pd.SemVer) + assert.IsType(t, &npm.Metadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + assert.Len(t, pd.VersionProperties, 1) + assert.Equal(t, npm.TagProperty, pd.VersionProperties[0].Name) + assert.Equal(t, packageTag, pd.VersionProperties[0].Value) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, filename, pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + require.NoError(t, err) + assert.Equal(t, int64(192), pb.Size) + }) + + t.Run("UploadExists", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", root, strings.NewReader(buildUpload(packageVersion))). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusConflict) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/-/%s/%s", root, packageVersion, filename)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + b, _ := base64.StdEncoding.DecodeString(data) + assert.Equal(t, b, resp.Body.Bytes()) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/-/%s", root, filename)). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, b, resp.Body.Bytes()) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNpm) + require.NoError(t, err) + assert.Len(t, pvs, 1) + assert.Equal(t, int64(2), pvs[0].DownloadCount) + }) + + t.Run("PackageMetadata", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/packages/%s/npm/%s", user.Name, "does-not-exist")). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", root). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var result npm.PackageMetadata + DecodeJSON(t, resp, &result) + + assert.Equal(t, packageName, result.ID) + assert.Equal(t, packageName, result.Name) + assert.Equal(t, packageDescription, result.Description) + assert.Contains(t, result.DistTags, packageTag) + assert.Equal(t, packageVersion, result.DistTags[packageTag]) + assert.Equal(t, packageAuthor, result.Author.Name) + assert.Contains(t, result.Versions, packageVersion) + pmv := result.Versions[packageVersion] + assert.Equal(t, fmt.Sprintf("%s@%s", packageName, packageVersion), pmv.ID) + assert.Equal(t, packageName, pmv.Name) + assert.Equal(t, packageDescription, pmv.Description) + assert.Equal(t, packageAuthor, pmv.Author.Name) + assert.Equal(t, packageBinPath, pmv.Bin[packageBinName]) + assert.Equal(t, "sha512-yA4FJsVhetynGfOC1jFf79BuS+jrHbm0fhh+aHzCQkOaOBXKf9oBnC4a6DnLLnEsHQDRLYd00cwj8sCXpC+wIg==", pmv.Dist.Integrity) + assert.Equal(t, "aaa7eaf852a948b0aa05afeda35b1badca155d90", pmv.Dist.Shasum) + assert.Equal(t, fmt.Sprintf("%s%s/-/%s/%s", setting.AppURL, root[1:], packageVersion, filename), pmv.Dist.Tarball) + assert.Equal(t, repoType, result.Repository.Type) + assert.Equal(t, repoURL, result.Repository.URL) + }) + + t.Run("AddTag", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + test := func(t *testing.T, status int, tag, version string) { + req := NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/%s", tagsRoot, tag), strings.NewReader(`"`+version+`"`)). + AddTokenAuth(token) + MakeRequest(t, req, status) + } + + test(t, http.StatusBadRequest, "1.0", packageVersion) + test(t, http.StatusBadRequest, "v1.0", packageVersion) + test(t, http.StatusNotFound, packageTag2, "1.2") + test(t, http.StatusOK, packageTag, packageVersion) + test(t, http.StatusOK, packageTag2, packageVersion) + }) + + t.Run("ListTags", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", tagsRoot). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var result map[string]string + DecodeJSON(t, resp, &result) + + assert.Len(t, result, 2) + assert.Contains(t, result, packageTag) + assert.Equal(t, packageVersion, result[packageTag]) + assert.Contains(t, result, packageTag2) + assert.Equal(t, packageVersion, result[packageTag2]) + }) + + t.Run("PackageMetadataDistTags", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", root). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var result npm.PackageMetadata + DecodeJSON(t, resp, &result) + + assert.Len(t, result.DistTags, 2) + assert.Contains(t, result.DistTags, packageTag) + assert.Equal(t, packageVersion, result.DistTags[packageTag]) + assert.Contains(t, result.DistTags, packageTag2) + assert.Equal(t, packageVersion, result.DistTags[packageTag2]) + }) + + t.Run("DeleteTag", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + test := func(t *testing.T, status int, tag string) { + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%s", tagsRoot, tag)). + AddTokenAuth(token) + MakeRequest(t, req, status) + } + + test(t, http.StatusBadRequest, "v1.0") + test(t, http.StatusBadRequest, "1.0") + test(t, http.StatusOK, "dummy") + test(t, http.StatusOK, packageTag2) + }) + + t.Run("Search", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + url := fmt.Sprintf("/api/packages/%s/npm/-/v1/search", user.Name) + + cases := []struct { + Query string + Skip int + Take int + ExpectedTotal int64 + ExpectedResults int + }{ + {"", 0, 0, 1, 1}, + {"", 0, 10, 1, 1}, + {"gitea", 0, 10, 0, 0}, + {"test", 0, 10, 1, 1}, + {"test", 1, 10, 1, 0}, + } + + for i, c := range cases { + req := NewRequest(t, "GET", fmt.Sprintf("%s?text=%s&from=%d&size=%d", url, c.Query, c.Skip, c.Take)) + resp := MakeRequest(t, req, http.StatusOK) + + var result npm.PackageSearch + DecodeJSON(t, resp, &result) + + assert.Equal(t, c.ExpectedTotal, result.Total, "case %d: unexpected total hits", i) + assert.Len(t, result.Objects, c.ExpectedResults, "case %d: unexpected result count", i) + } + }) + + t.Run("Delete", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", root, strings.NewReader(buildUpload(packageVersion+"-dummy"))). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequest(t, "PUT", root+"/-rev/dummy") + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "PUT", root+"/-rev/dummy"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + t.Run("Version", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNpm) + require.NoError(t, err) + assert.Len(t, pvs, 2) + + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/%s/-rev/dummy", root, packageVersion, filename)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/%s/-rev/dummy", root, packageVersion, filename)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + pvs, err = packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNpm) + require.NoError(t, err) + assert.Len(t, pvs, 1) + }) + + t.Run("Full", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNpm) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + req := NewRequest(t, "DELETE", root+"/-rev/dummy") + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", root+"/-rev/dummy"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + pvs, err = packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNpm) + require.NoError(t, err) + assert.Empty(t, pvs) + }) + }) +} diff --git a/tests/integration/api_packages_nuget_test.go b/tests/integration/api_packages_nuget_test.go new file mode 100644 index 0000000..03e2176 --- /dev/null +++ b/tests/integration/api_packages_nuget_test.go @@ -0,0 +1,763 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/http/httptest" + neturl "net/url" + "strconv" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + nuget_module "code.gitea.io/gitea/modules/packages/nuget" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/packages/nuget" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func addNuGetAPIKeyHeader(req *RequestWrapper, token string) { + req.SetHeader("X-NuGet-ApiKey", token) +} + +func decodeXML(t testing.TB, resp *httptest.ResponseRecorder, v any) { + t.Helper() + + require.NoError(t, xml.NewDecoder(resp.Body).Decode(v)) +} + +func TestPackageNuGet(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + type FeedEntryProperties struct { + Version string `xml:"Version"` + NormalizedVersion string `xml:"NormalizedVersion"` + Authors string `xml:"Authors"` + Dependencies string `xml:"Dependencies"` + Description string `xml:"Description"` + VersionDownloadCount nuget.TypedValue[int64] `xml:"VersionDownloadCount"` + DownloadCount nuget.TypedValue[int64] `xml:"DownloadCount"` + PackageSize nuget.TypedValue[int64] `xml:"PackageSize"` + Created nuget.TypedValue[time.Time] `xml:"Created"` + LastUpdated nuget.TypedValue[time.Time] `xml:"LastUpdated"` + Published nuget.TypedValue[time.Time] `xml:"Published"` + ProjectURL string `xml:"ProjectUrl,omitempty"` + ReleaseNotes string `xml:"ReleaseNotes,omitempty"` + RequireLicenseAcceptance nuget.TypedValue[bool] `xml:"RequireLicenseAcceptance"` + Title string `xml:"Title"` + } + + type FeedEntry struct { + XMLName xml.Name `xml:"entry"` + Properties *FeedEntryProperties `xml:"properties"` + Content string `xml:",innerxml"` + } + + type FeedEntryLink struct { + Rel string `xml:"rel,attr"` + Href string `xml:"href,attr"` + } + + type FeedResponse struct { + XMLName xml.Name `xml:"feed"` + Links []FeedEntryLink `xml:"link"` + Entries []*FeedEntry `xml:"entry"` + Count int64 `xml:"count"` + } + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + token := getUserToken(t, user.Name, auth_model.AccessTokenScopeWritePackage) + + packageName := "test.package" + packageVersion := "1.0.3" + packageAuthors := "KN4CK3R" + packageDescription := "Gitea Test Package" + symbolFilename := "test.pdb" + symbolID := "d910bb6948bd4c6cb40155bcf52c3c94" + + createPackage := func(id, version string) io.Reader { + var buf bytes.Buffer + archive := zip.NewWriter(&buf) + w, _ := archive.Create("package.nuspec") + w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?> + <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"> + <metadata> + <id>` + id + `</id> + <version>` + version + `</version> + <authors>` + packageAuthors + `</authors> + <description>` + packageDescription + `</description> + <dependencies> + <group targetFramework=".NETStandard2.0"> + <dependency id="Microsoft.CSharp" version="4.5.0" /> + </group> + </dependencies> + </metadata> + </package>`)) + archive.Close() + return &buf + } + + nuspec := `<?xml version="1.0" encoding="utf-8"?> + <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"> + <metadata> + <id>` + packageName + `</id> + <version>` + packageVersion + `</version> + <authors>` + packageAuthors + `</authors> + <description>` + packageDescription + `</description> + <dependencies> + <group targetFramework=".NETStandard2.0"> + <dependency id="Microsoft.CSharp" version="4.5.0" /> + </group> + </dependencies> + </metadata> + </package>` + content, _ := io.ReadAll(createPackage(packageName, packageVersion)) + + url := fmt.Sprintf("/api/packages/%s/nuget", user.Name) + + t.Run("ServiceIndex", func(t *testing.T) { + t.Run("v2", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Visibility: structs.VisibleTypePrivate}) + + cases := []struct { + Owner string + UseBasicAuth bool + UseTokenAuth bool + }{ + {privateUser.Name, false, false}, + {privateUser.Name, true, false}, + {privateUser.Name, false, true}, + {user.Name, false, false}, + {user.Name, true, false}, + {user.Name, false, true}, + } + + for _, c := range cases { + url := fmt.Sprintf("/api/packages/%s/nuget", c.Owner) + + req := NewRequest(t, "GET", url) + if c.UseBasicAuth { + req.AddBasicAuth(user.Name) + } else if c.UseTokenAuth { + addNuGetAPIKeyHeader(req, token) + } + resp := MakeRequest(t, req, http.StatusOK) + + var result nuget.ServiceIndexResponseV2 + decodeXML(t, resp, &result) + + assert.Equal(t, setting.AppURL+url[1:], result.Base) + assert.Equal(t, "Packages", result.Workspace.Collection.Href) + } + }) + + t.Run("v3", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Visibility: structs.VisibleTypePrivate}) + + cases := []struct { + Owner string + UseBasicAuth bool + UseTokenAuth bool + }{ + {privateUser.Name, false, false}, + {privateUser.Name, true, false}, + {privateUser.Name, false, true}, + {user.Name, false, false}, + {user.Name, true, false}, + {user.Name, false, true}, + } + + for _, c := range cases { + url := fmt.Sprintf("/api/packages/%s/nuget", c.Owner) + + req := NewRequest(t, "GET", fmt.Sprintf("%s/index.json", url)) + if c.UseBasicAuth { + req.AddBasicAuth(user.Name) + } else if c.UseTokenAuth { + addNuGetAPIKeyHeader(req, token) + } + resp := MakeRequest(t, req, http.StatusOK) + + var result nuget.ServiceIndexResponseV3 + DecodeJSON(t, resp, &result) + + assert.Equal(t, "3.0.0", result.Version) + assert.NotEmpty(t, result.Resources) + + root := setting.AppURL + url[1:] + for _, r := range result.Resources { + switch r.Type { + case "SearchQueryService": + fallthrough + case "SearchQueryService/3.0.0-beta": + fallthrough + case "SearchQueryService/3.0.0-rc": + assert.Equal(t, root+"/query", r.ID) + case "RegistrationsBaseUrl": + fallthrough + case "RegistrationsBaseUrl/3.0.0-beta": + fallthrough + case "RegistrationsBaseUrl/3.0.0-rc": + assert.Equal(t, root+"/registration", r.ID) + case "PackageBaseAddress/3.0.0": + assert.Equal(t, root+"/package", r.ID) + case "PackagePublish/2.0.0": + assert.Equal(t, root, r.ID) + } + } + } + }) + }) + + t.Run("Upload", func(t *testing.T) { + t.Run("DependencyPackage", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", url, bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNuGet) + require.NoError(t, err) + assert.Len(t, pvs, 1, "Should have one version") + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.NotNil(t, pd.SemVer) + assert.IsType(t, &nuget_module.Metadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 2, "Should have 2 files: nuget and nuspec") + assert.Equal(t, fmt.Sprintf("%s.%s.nupkg", packageName, packageVersion), pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + require.NoError(t, err) + assert.Equal(t, int64(len(content)), pb.Size) + + req = NewRequestWithBody(t, "PUT", url, bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusConflict) + }) + + t.Run("SymbolPackage", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + createSymbolPackage := func(id, packageType string) io.Reader { + var buf bytes.Buffer + archive := zip.NewWriter(&buf) + + w, _ := archive.Create("package.nuspec") + w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?> + <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"> + <metadata> + <id>` + id + `</id> + <version>` + packageVersion + `</version> + <authors>` + packageAuthors + `</authors> + <description>` + packageDescription + `</description> + <packageTypes><packageType name="` + packageType + `" /></packageTypes> + </metadata> + </package>`)) + + w, _ = archive.Create(symbolFilename) + b, _ := base64.StdEncoding.DecodeString(`QlNKQgEAAQAAAAAADAAAAFBEQiB2MS4wAAAAAAAABgB8AAAAWAAAACNQZGIAAAAA1AAAAAgBAAAj +fgAA3AEAAAQAAAAjU3RyaW5ncwAAAADgAQAABAAAACNVUwDkAQAAMAAAACNHVUlEAAAAFAIAACgB +AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`) + w.Write(b) + + archive.Close() + return &buf + } + + req := NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/symbolpackage", url), createSymbolPackage("unknown-package", "SymbolsPackage")). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/symbolpackage", url), createSymbolPackage(packageName, "DummyPackage")). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/symbolpackage", url), createSymbolPackage(packageName, "SymbolsPackage")). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNuGet) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.NotNil(t, pd.SemVer) + assert.IsType(t, &nuget_module.Metadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 4, "Should have 4 files: nupkg, snupkg, nuspec and pdb") + for _, pf := range pfs { + switch pf.Name { + case fmt.Sprintf("%s.%s.nupkg", packageName, packageVersion): + assert.True(t, pf.IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID) + require.NoError(t, err) + assert.Equal(t, int64(414), pb.Size) + case fmt.Sprintf("%s.%s.snupkg", packageName, packageVersion): + assert.False(t, pf.IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID) + require.NoError(t, err) + assert.Equal(t, int64(616), pb.Size) + case fmt.Sprintf("%s.nuspec", packageName): + assert.False(t, pf.IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID) + require.NoError(t, err) + assert.Equal(t, int64(453), pb.Size) + case symbolFilename: + assert.False(t, pf.IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID) + require.NoError(t, err) + assert.Equal(t, int64(160), pb.Size) + + pps, err := packages.GetProperties(db.DefaultContext, packages.PropertyTypeFile, pf.ID) + require.NoError(t, err) + assert.Len(t, pps, 1) + assert.Equal(t, nuget_module.PropertySymbolID, pps[0].Name) + assert.Equal(t, symbolID, pps[0].Value) + default: + assert.FailNow(t, "unexpected file: %v", pf.Name) + } + } + + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/symbolpackage", url), createSymbolPackage(packageName, "SymbolsPackage")). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusConflict) + }) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + checkDownloadCount := func(count int64) { + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNuGet) + require.NoError(t, err) + assert.Len(t, pvs, 1) + assert.Equal(t, count, pvs[0].DownloadCount) + } + + checkDownloadCount(0) + + req := NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s.%s.nupkg", url, packageName, packageVersion, packageName, packageVersion)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, content, resp.Body.Bytes()) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s.nuspec", url, packageName, packageVersion, packageName)). + AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, nuspec, resp.Body.String()) + + checkDownloadCount(1) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s.%s.snupkg", url, packageName, packageVersion, packageName, packageVersion)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + checkDownloadCount(1) + + t.Run("Symbol", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/symbols/%s/%sFFFFFFFF/gitea.pdb", url, symbolFilename, symbolID)) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/symbols/%s/%sFFFFFFFF/%s", url, symbolFilename, "00000000000000000000000000000000", symbolFilename)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/symbols/%s/%sFFFFffff/%s", url, symbolFilename, symbolID, symbolFilename)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + checkDownloadCount(1) + }) + }) + + containsOneNextLink := func(t *testing.T, links []FeedEntryLink) func() bool { + return func() bool { + found := 0 + for _, l := range links { + if l.Rel == "next" { + found++ + u, err := neturl.Parse(l.Href) + require.NoError(t, err) + q := u.Query() + assert.Contains(t, q, "$skip") + assert.Contains(t, q, "$top") + assert.Equal(t, "1", q.Get("$skip")) + assert.Equal(t, "1", q.Get("$top")) + } + } + return found == 1 + } + } + + t.Run("SearchService", func(t *testing.T) { + cases := []struct { + Query string + Skip int + Take int + ExpectedTotal int64 + ExpectedResults int + ExpectedExactMatch bool + }{ + {"", 0, 0, 4, 4, false}, + {"", 0, 10, 4, 4, false}, + {"gitea", 0, 10, 0, 0, false}, + {"test", 0, 10, 1, 1, false}, + {"test", 1, 10, 1, 0, false}, + {"almost.similar", 0, 0, 3, 3, true}, + } + + fakePackages := []string{ + packageName, + "almost.similar.dependency", + "almost.similar", + "almost.similar.dependent", + } + + for _, fakePackageName := range fakePackages { + req := NewRequestWithBody(t, "PUT", url, createPackage(fakePackageName, "1.0.99")). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + } + + t.Run("v2", func(t *testing.T) { + t.Run("Search()", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + for i, c := range cases { + req := NewRequest(t, "GET", fmt.Sprintf("%s/Search()?searchTerm='%s'&$skip=%d&$top=%d", url, c.Query, c.Skip, c.Take)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result FeedResponse + decodeXML(t, resp, &result) + + assert.Equal(t, c.ExpectedTotal, result.Count, "case %d: unexpected total hits", i) + assert.Len(t, result.Entries, c.ExpectedResults, "case %d: unexpected result count", i) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/Search()/$count?searchTerm='%s'&$skip=%d&$top=%d", url, c.Query, c.Skip, c.Take)). + AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, strconv.FormatInt(c.ExpectedTotal, 10), resp.Body.String(), "case %d: unexpected total hits", i) + } + }) + + t.Run("Packages()", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + for i, c := range cases { + req := NewRequest(t, "GET", fmt.Sprintf("%s/Packages()?$filter=substringof('%s',tolower(Id))&$skip=%d&$top=%d", url, c.Query, c.Skip, c.Take)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result FeedResponse + decodeXML(t, resp, &result) + + assert.Equal(t, c.ExpectedTotal, result.Count, "case %d: unexpected total hits", i) + assert.Len(t, result.Entries, c.ExpectedResults, "case %d: unexpected result count", i) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/Packages()/$count?$filter=substringof('%s',tolower(Id))&$skip=%d&$top=%d", url, c.Query, c.Skip, c.Take)). + AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, strconv.FormatInt(c.ExpectedTotal, 10), resp.Body.String(), "case %d: unexpected total hits", i) + } + }) + + t.Run("Packages()", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("substringof", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + for i, c := range cases { + req := NewRequest(t, "GET", fmt.Sprintf("%s/Packages()?$filter=substringof('%s',tolower(Id))&$skip=%d&$top=%d", url, c.Query, c.Skip, c.Take)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result FeedResponse + decodeXML(t, resp, &result) + + assert.Equal(t, c.ExpectedTotal, result.Count, "case %d: unexpected total hits", i) + assert.Len(t, result.Entries, c.ExpectedResults, "case %d: unexpected result count", i) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/Packages()/$count?$filter=substringof('%s',tolower(Id))&$skip=%d&$top=%d", url, c.Query, c.Skip, c.Take)). + AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, strconv.FormatInt(c.ExpectedTotal, 10), resp.Body.String(), "case %d: unexpected total hits", i) + } + }) + + t.Run("IdEq", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + for i, c := range cases { + if c.Query == "" { + // Ignore the `tolower(Id) eq ''` as it's unlikely to happen + continue + } + req := NewRequest(t, "GET", fmt.Sprintf("%s/Packages()?$filter=(tolower(Id) eq '%s')&$skip=%d&$top=%d", url, c.Query, c.Skip, c.Take)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result FeedResponse + decodeXML(t, resp, &result) + + expectedCount := 0 + if c.ExpectedExactMatch { + expectedCount = 1 + } + + assert.Equal(t, int64(expectedCount), result.Count, "case %d: unexpected total hits", i) + assert.Len(t, result.Entries, expectedCount, "case %d: unexpected result count", i) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/Packages()/$count?$filter=(tolower(Id) eq '%s')&$skip=%d&$top=%d", url, c.Query, c.Skip, c.Take)). + AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, strconv.FormatInt(int64(expectedCount), 10), resp.Body.String(), "case %d: unexpected total hits", i) + } + }) + }) + + t.Run("Next", func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("%s/Search()?searchTerm='test'&$skip=0&$top=1", url)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result FeedResponse + decodeXML(t, resp, &result) + + assert.Condition(t, containsOneNextLink(t, result.Links)) + }) + }) + + t.Run("v3", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + for i, c := range cases { + req := NewRequest(t, "GET", fmt.Sprintf("%s/query?q=%s&skip=%d&take=%d", url, c.Query, c.Skip, c.Take)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result nuget.SearchResultResponse + DecodeJSON(t, resp, &result) + + assert.Equal(t, c.ExpectedTotal, result.TotalHits, "case %d: unexpected total hits", i) + assert.Len(t, result.Data, c.ExpectedResults, "case %d: unexpected result count", i) + } + + t.Run("EnforceGrouped", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, "PUT", url, createPackage(packageName+".dummy", "1.0.0")). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/query?q=%s", url, packageName)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result nuget.SearchResultResponse + DecodeJSON(t, resp, &result) + + assert.EqualValues(t, 2, result.TotalHits) + assert.Len(t, result.Data, 2) + for _, sr := range result.Data { + if sr.ID == packageName { + assert.Len(t, sr.Versions, 2) + } else { + assert.Len(t, sr.Versions, 1) + } + } + + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s", url, packageName+".dummy", "1.0.0")). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + }) + }) + + for _, fakePackageName := range fakePackages { + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s", url, fakePackageName, "1.0.99")). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + } + }) + + t.Run("RegistrationService", func(t *testing.T) { + indexURL := fmt.Sprintf("%s%s/registration/%s/index.json", setting.AppURL, url[1:], packageName) + leafURL := fmt.Sprintf("%s%s/registration/%s/%s.json", setting.AppURL, url[1:], packageName, packageVersion) + contentURL := fmt.Sprintf("%s%s/package/%s/%s/%s.%s.nupkg", setting.AppURL, url[1:], packageName, packageVersion, packageName, packageVersion) + + t.Run("RegistrationIndex", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/registration/%s/index.json", url, packageName)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result nuget.RegistrationIndexResponse + DecodeJSON(t, resp, &result) + + assert.Equal(t, indexURL, result.RegistrationIndexURL) + assert.Equal(t, 1, result.Count) + assert.Len(t, result.Pages, 1) + assert.Equal(t, indexURL, result.Pages[0].RegistrationPageURL) + assert.Equal(t, packageVersion, result.Pages[0].Lower) + assert.Equal(t, packageVersion, result.Pages[0].Upper) + assert.Equal(t, 1, result.Pages[0].Count) + assert.Len(t, result.Pages[0].Items, 1) + assert.Equal(t, packageName, result.Pages[0].Items[0].CatalogEntry.ID) + assert.Equal(t, packageVersion, result.Pages[0].Items[0].CatalogEntry.Version) + assert.Equal(t, packageAuthors, result.Pages[0].Items[0].CatalogEntry.Authors) + assert.Equal(t, packageDescription, result.Pages[0].Items[0].CatalogEntry.Description) + assert.Equal(t, leafURL, result.Pages[0].Items[0].CatalogEntry.CatalogLeafURL) + assert.Equal(t, contentURL, result.Pages[0].Items[0].CatalogEntry.PackageContentURL) + }) + + t.Run("RegistrationLeaf", func(t *testing.T) { + t.Run("v2", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/Packages(Id='%s',Version='%s')", url, packageName, packageVersion)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result FeedEntry + decodeXML(t, resp, &result) + + assert.Equal(t, packageName, result.Properties.Title) + assert.Equal(t, packageVersion, result.Properties.Version) + assert.Equal(t, packageAuthors, result.Properties.Authors) + assert.Equal(t, packageDescription, result.Properties.Description) + assert.Equal(t, "Microsoft.CSharp:4.5.0:.NETStandard2.0", result.Properties.Dependencies) + }) + + t.Run("v3", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/registration/%s/%s.json", url, packageName, packageVersion)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result nuget.RegistrationLeafResponse + DecodeJSON(t, resp, &result) + + assert.Equal(t, leafURL, result.RegistrationLeafURL) + assert.Equal(t, contentURL, result.PackageContentURL) + assert.Equal(t, indexURL, result.RegistrationIndexURL) + }) + }) + }) + + t.Run("PackageService", func(t *testing.T) { + t.Run("v2", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/FindPackagesById()?id='%s'&$top=1", url, packageName)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result FeedResponse + decodeXML(t, resp, &result) + + assert.Len(t, result.Entries, 1) + assert.Equal(t, packageVersion, result.Entries[0].Properties.Version) + assert.Condition(t, containsOneNextLink(t, result.Links)) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/FindPackagesById()/$count?id='%s'", url, packageName)). + AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "1", resp.Body.String()) + }) + + t.Run("v3", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/index.json", url, packageName)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var result nuget.PackageVersionsResponse + DecodeJSON(t, resp, &result) + + assert.Len(t, result.Versions, 1) + assert.Equal(t, packageVersion, result.Versions[0]) + }) + }) + + t.Run("Delete", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s", url, packageName, packageVersion)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNuGet) + require.NoError(t, err) + assert.Empty(t, pvs) + }) + + t.Run("DownloadNotExists", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s.%s.nupkg", url, packageName, packageVersion, packageName, packageVersion)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s.%s.snupkg", url, packageName, packageVersion, packageName, packageVersion)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("DeleteNotExists", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/package/%s/%s", url, packageName, packageVersion)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + }) +} diff --git a/tests/integration/api_packages_pub_test.go b/tests/integration/api_packages_pub_test.go new file mode 100644 index 0000000..d6bce30 --- /dev/null +++ b/tests/integration/api_packages_pub_test.go @@ -0,0 +1,182 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + pub_module "code.gitea.io/gitea/modules/packages/pub" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackagePub(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + token := "Bearer " + getUserToken(t, user.Name, auth_model.AccessTokenScopeWritePackage) + + packageName := "test_package" + packageVersion := "1.0.1" + packageDescription := "Test Description" + + filename := fmt.Sprintf("%s.tar.gz", packageVersion) + + pubspecContent := `name: ` + packageName + ` +version: ` + packageVersion + ` +description: ` + packageDescription + + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + archive := tar.NewWriter(zw) + archive.WriteHeader(&tar.Header{ + Name: "pubspec.yaml", + Mode: 0o600, + Size: int64(len(pubspecContent)), + }) + archive.Write([]byte(pubspecContent)) + archive.Close() + zw.Close() + content := buf.Bytes() + + root := fmt.Sprintf("/api/packages/%s/pub", user.Name) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadURL := root + "/api/packages/versions/new" + + req := NewRequest(t, "GET", uploadURL) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "GET", uploadURL). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + type UploadRequest struct { + URL string `json:"url"` + Fields map[string]string `json:"fields"` + } + + var result UploadRequest + DecodeJSON(t, resp, &result) + + assert.Empty(t, result.Fields) + + uploadFile := func(t *testing.T, url string, content []byte, expectedStatus int) *httptest.ResponseRecorder { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "dummy.tar.gz") + _, _ = io.Copy(part, bytes.NewReader(content)) + + _ = writer.Close() + + req := NewRequestWithBody(t, "POST", url, body). + SetHeader("Content-Type", writer.FormDataContentType()). + AddTokenAuth(token) + return MakeRequest(t, req, expectedStatus) + } + + resp = uploadFile(t, result.URL, content, http.StatusNoContent) + + req = NewRequest(t, "GET", resp.Header().Get("Location")). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypePub) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.NotNil(t, pd.SemVer) + assert.IsType(t, &pub_module.Metadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, filename, pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + require.NoError(t, err) + assert.Equal(t, int64(len(content)), pb.Size) + + _ = uploadFile(t, result.URL, content, http.StatusConflict) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/api/packages/%s/%s", root, packageName, packageVersion)) + resp := MakeRequest(t, req, http.StatusOK) + + type VersionMetadata struct { + Version string `json:"version"` + ArchiveURL string `json:"archive_url"` + Published time.Time `json:"published"` + Pubspec any `json:"pubspec,omitempty"` + } + + var result VersionMetadata + DecodeJSON(t, resp, &result) + + assert.Equal(t, packageVersion, result.Version) + assert.NotNil(t, result.Pubspec) + + req = NewRequest(t, "GET", result.ArchiveURL) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, content, resp.Body.Bytes()) + }) + + t.Run("EnumeratePackageVersions", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/api/packages/%s", root, packageName)) + resp := MakeRequest(t, req, http.StatusOK) + + type VersionMetadata struct { + Version string `json:"version"` + ArchiveURL string `json:"archive_url"` + Published time.Time `json:"published"` + Pubspec any `json:"pubspec,omitempty"` + } + + type PackageVersions struct { + Name string `json:"name"` + Latest *VersionMetadata `json:"latest"` + Versions []*VersionMetadata `json:"versions"` + } + + var result PackageVersions + DecodeJSON(t, resp, &result) + + assert.Equal(t, packageName, result.Name) + assert.NotNil(t, result.Latest) + assert.Len(t, result.Versions, 1) + assert.Equal(t, result.Latest.Version, result.Versions[0].Version) + assert.Equal(t, packageVersion, result.Latest.Version) + assert.NotNil(t, result.Latest.Pubspec) + }) +} diff --git a/tests/integration/api_packages_pypi_test.go b/tests/integration/api_packages_pypi_test.go new file mode 100644 index 0000000..ef03dbe --- /dev/null +++ b/tests/integration/api_packages_pypi_test.go @@ -0,0 +1,183 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "regexp" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/packages/pypi" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackagePyPI(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + packageName := "test-package" + packageVersion := "1!1.0.1+r1234" + packageAuthor := "KN4CK3R" + packageDescription := "Test Description" + + content := "test" + hashSHA256 := "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + + root := fmt.Sprintf("/api/packages/%s/pypi", user.Name) + + uploadFile := func(t *testing.T, filename, content string, expectedStatus int) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("content", filename) + _, _ = io.Copy(part, strings.NewReader(content)) + + writer.WriteField("name", packageName) + writer.WriteField("version", packageVersion) + writer.WriteField("author", packageAuthor) + writer.WriteField("summary", packageDescription) + writer.WriteField("description", packageDescription) + writer.WriteField("sha256_digest", hashSHA256) + writer.WriteField("requires_python", "3.6") + + _ = writer.Close() + + req := NewRequestWithBody(t, "POST", root, body). + SetHeader("Content-Type", writer.FormDataContentType()). + AddBasicAuth(user.Name) + MakeRequest(t, req, expectedStatus) + } + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + filename := "test.whl" + uploadFile(t, filename, content, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypePyPI) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.Nil(t, pd.SemVer) + assert.IsType(t, &pypi.Metadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, filename, pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + require.NoError(t, err) + assert.Equal(t, int64(4), pb.Size) + }) + + t.Run("UploadAddFile", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + filename := "test.tar.gz" + uploadFile(t, filename, content, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypePyPI) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.Nil(t, pd.SemVer) + assert.IsType(t, &pypi.Metadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 2) + + pf, err := packages.GetFileForVersionByName(db.DefaultContext, pvs[0].ID, filename, packages.EmptyFileKey) + require.NoError(t, err) + assert.Equal(t, filename, pf.Name) + assert.True(t, pf.IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID) + require.NoError(t, err) + assert.Equal(t, int64(4), pb.Size) + }) + + t.Run("UploadHashMismatch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + filename := "test2.whl" + uploadFile(t, filename, "dummy", http.StatusBadRequest) + }) + + t.Run("UploadExists", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadFile(t, "test.whl", content, http.StatusConflict) + uploadFile(t, "test.tar.gz", content, http.StatusConflict) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + downloadFile := func(filename string) { + req := NewRequest(t, "GET", fmt.Sprintf("%s/files/%s/%s/%s", root, packageName, packageVersion, filename)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, []byte(content), resp.Body.Bytes()) + } + + downloadFile("test.whl") + downloadFile("test.tar.gz") + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypePyPI) + require.NoError(t, err) + assert.Len(t, pvs, 1) + assert.Equal(t, int64(2), pvs[0].DownloadCount) + }) + + t.Run("PackageMetadata", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/simple/%s", root, packageName)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + nodes := htmlDoc.doc.Find("a").Nodes + assert.Len(t, nodes, 2) + + hrefMatcher := regexp.MustCompile(fmt.Sprintf(`%s/files/%s/%s/test\..+#sha256=%s`, root, regexp.QuoteMeta(packageName), regexp.QuoteMeta(packageVersion), hashSHA256)) + + for _, a := range nodes { + for _, att := range a.Attr { + switch att.Key { + case "href": + assert.Regexp(t, hrefMatcher, att.Val) + case "data-requires-python": + assert.Equal(t, "3.6", att.Val) + default: + t.Fail() + } + } + } + }) +} diff --git a/tests/integration/api_packages_rpm_test.go b/tests/integration/api_packages_rpm_test.go new file mode 100644 index 0000000..853c8f0 --- /dev/null +++ b/tests/integration/api_packages_rpm_test.go @@ -0,0 +1,462 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + rpm_module "code.gitea.io/gitea/modules/packages/rpm" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/tests" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/sassoftware/go-rpmutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageRpm(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + packageName := "gitea-test" + packageVersion := "1.0.2-1" + packageArchitecture := "x86_64" + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + base64RpmPackageContent := `H4sICFayB2QCAGdpdGVhLXRlc3QtMS4wLjItMS14ODZfNjQucnBtAO2YV4gTQRjHJzl7wbNhhxVF +VNwk2zd2PdvZ9Sxnd3Z3NllNsmF3o6congVFsWFHRWwIImIXfRER0QcRfPBJEXvvBQvWSfZTT0VQ +8TF/MuU33zcz3+zOJGEe73lyuQBRBWKWRzDrEddjuVAkxLMc+lsFUOWfm5bvvReAalWECg/TsivU +dyKa0U61aVnl6wj0Uxe4nc8F92hZiaYE8CO/P0r7/Quegr0c7M/AvoCaGZEIWNGUqMHrhhGROIUT +Zc7gOAOraoQzCNZ0WdU0HpEI5jiB4zlek3gT85wqCBomhomxoGCs8wImWMImbxqKgXVNUKKaqShR +STKVKK9glFUNcf2g+/t27xs16v5x/eyOKftVGlIhyiuvvPLKK6+88sorr7zyyiuvvPKCO5HPnz+v +pGVhhXsTsFVeSstuWR9anwU+Bk3Vch5wTwL3JkHg+8C1gR8A169wj1KdpobAj4HbAT+Be5VewE+h +fz/g52AvBX4N9vHAb4AnA7+F8ePAH8BuA38ELgf+BLzQ50oIeBlw0OdAOXAlP57AGuCsbwGtbgCu +DrwRuAb4bwau6T/PwFbgWsDXgWuD/y3gOmC/B1wI/Bi4AcT3Arih3z9YCNzI9w9m/YKUG4Nd9N9z +pSZgHwrcFPgccFt//OADGE+F/q+Ao+D/FrijzwV1gbv4/QvaAHcFDgF3B5aB+wB3Be7rz1dQCtwP +eDxwMcw3GbgU7AasdwzYE8DjwT4L/CeAvRx4IvBCYA3iWQds+FzpDjABfghsAj8BTgA/A/b8+StX +A84A1wKe5s9fuRB4JpzHZv55rL8a/Dv49vpn/PErR4BvQX8Z+Db4l2W5CH2/f0W5+1fEoeFDBzFp +rE/FMcK4mWQSOzN+aDOIqztW2rPsFKIyqh7sQERR42RVMSKihnzVHlQ8Ag0YLBYNEIajkhmuR5Io +7nlpt2M4nJs0ZNkoYaUyZahMlSfJImr1n1WjFVNCPCaTZgYNGdGL8YN2mX8WHfA/C7ViHJK0pxHG +SrkeTiSI4T+7ubf85yrzRCQRQ5EVxVAjvIBVRY/KRFAVReIkhfARSddNSceayQkGliIKb0q8RAxJ +5QWNVxHIsW3Pz369bw+5jh5y0klE9Znqm0dF57b0HbGy2A5lVUBTZZrqZjdUjYoprFmpsBtHP5d0 ++ISltS2yk2mHuC4x+lgJMhgnidvuqy3b0suK0bm+tw3FMxI2zjm7/fA0MtQhplX2s7nYLZ2ZC0yg +CxJZDokhORTJlrlcCvG5OieGBERlVCs7CfuS6WzQ/T2j+9f92BWxTFEcp2IkYccYGp2LYySEfreq +irue4WRF5XkpKovw2wgpq2rZBI8bQZkzxEkiYaNwxnXCCVvHidzIiB3CM2yMYdNWmjDsaLovaE4c +x3a6mLaTxB7rEj3jWN4M2p7uwPaa1GfI8BHFfcZMKhkycnhR7y781/a+A4t7FpWWTupRUtKbegwZ +XMKwJinTSe70uhRcj55qNu3YHtE922Fdz7FTMTq9Q3TbMdiYrrPudMvT44S6u2miu138eC0tTN9D +2CFGHHtQsHHsGCRFDFbXuT9wx6mUTZfseydlkWZeJkW6xOgYjqXT+LA7I6XHaUx2xmUzqelWymA9 +rCXI9+D1BHbjsITssqhBNysw0tOWjcpmIh6+aViYPfftw8ZSGfRVPUqKiosZj5R5qGmk/8AjjRbZ +d8b3vvngdPHx3HvMeCarIk7VVSwbgoZVkceEVyOmyUmGxBGNYDVKSFSOGlIkGqWnUZFkiY/wsmhK +Mu0UFYgZ/bYnuvn/vz4wtCz8qMwsHUvP0PX3tbYFUctAPdrY6tiiDtcCddDECahx7SuVNP5dpmb5 +9tMDyaXb7OAlk5acuPn57ss9mw6Wym0m1Fq2cej7tUt2LL4/b8enXU2fndk+fvv57ndnt55/cQob +7tpp/pEjDS7cGPZ6BY430+7danDq6f42Nw49b9F7zp6BiKpJb9s5P0AYN2+L159cnrur636rx+v1 +7ae1K28QbMMcqI8CqwIrgwg9nTOp8Oj9q81plUY7ZuwXN8Vvs8wbAAA=` + rpmPackageContent, err := base64.StdEncoding.DecodeString(base64RpmPackageContent) + require.NoError(t, err) + + zr, err := gzip.NewReader(bytes.NewReader(rpmPackageContent)) + require.NoError(t, err) + + content, err := io.ReadAll(zr) + require.NoError(t, err) + + rootURL := fmt.Sprintf("/api/packages/%s/rpm", user.Name) + + for _, group := range []string{"", "el9", "el9/stable"} { + t.Run(fmt.Sprintf("[Group:%s]", group), func(t *testing.T) { + var groupParts []string + if group != "" { + groupParts = strings.Split(group, "/") + } + groupURL := strings.Join(append([]string{rootURL}, groupParts...), "/") + + t.Run("RepositoryConfig", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", groupURL+".repo") + resp := MakeRequest(t, req, http.StatusOK) + + expected := fmt.Sprintf(`[gitea-%s] +name=%s +baseurl=%s +enabled=1 +gpgcheck=1 +gpgkey=%sapi/packages/%s/rpm/repository.key`, + strings.Join(append([]string{user.LowerName}, groupParts...), "-"), + strings.Join(append([]string{user.Name, setting.AppName}, groupParts...), " - "), + util.URLJoin(setting.AppURL, groupURL), + setting.AppURL, + user.Name, + ) + + assert.Equal(t, expected, resp.Body.String()) + }) + + t.Run("RepositoryKey", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", rootURL+"/repository.key") + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "application/pgp-keys", resp.Header().Get("Content-Type")) + assert.Contains(t, resp.Body.String(), "-----BEGIN PGP PUBLIC KEY BLOCK-----") + }) + + t.Run("Upload", func(t *testing.T) { + url := groupURL + "/upload" + + req := NewRequestWithBody(t, "PUT", url, bytes.NewReader(content)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", url, bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeRpm) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.Nil(t, pd.SemVer) + assert.IsType(t, &rpm_module.VersionMetadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, fmt.Sprintf("%s-%s.%s.rpm", packageName, packageVersion, packageArchitecture), pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + require.NoError(t, err) + assert.Equal(t, int64(len(content)), pb.Size) + + req = NewRequestWithBody(t, "PUT", url, bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusConflict) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture)) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, content, resp.Body.Bytes()) + }) + + t.Run("Repository", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + url := groupURL + "/repodata" + + req := NewRequest(t, "HEAD", url+"/dummy.xml") + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", url+"/dummy.xml") + MakeRequest(t, req, http.StatusNotFound) + + t.Run("repomd.xml", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req = NewRequest(t, "HEAD", url+"/repomd.xml") + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", url+"/repomd.xml") + resp := MakeRequest(t, req, http.StatusOK) + + type Repomd struct { + XMLName xml.Name `xml:"repomd"` + Xmlns string `xml:"xmlns,attr"` + XmlnsRpm string `xml:"xmlns:rpm,attr"` + Data []struct { + Type string `xml:"type,attr"` + Checksum struct { + Value string `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"checksum"` + OpenChecksum struct { + Value string `xml:",chardata"` + Type string `xml:"type,attr"` + } `xml:"open-checksum"` + Location struct { + Href string `xml:"href,attr"` + } `xml:"location"` + Timestamp int64 `xml:"timestamp"` + Size int64 `xml:"size"` + OpenSize int64 `xml:"open-size"` + } `xml:"data"` + } + + var result Repomd + decodeXML(t, resp, &result) + + assert.Len(t, result.Data, 3) + for _, d := range result.Data { + assert.Equal(t, "sha256", d.Checksum.Type) + assert.NotEmpty(t, d.Checksum.Value) + assert.Equal(t, "sha256", d.OpenChecksum.Type) + assert.NotEmpty(t, d.OpenChecksum.Value) + assert.NotEqual(t, d.Checksum.Value, d.OpenChecksum.Value) + assert.Greater(t, d.OpenSize, d.Size) + + switch d.Type { + case "primary": + assert.EqualValues(t, 722, d.Size) + assert.EqualValues(t, 1759, d.OpenSize) + assert.Equal(t, "repodata/primary.xml.gz", d.Location.Href) + case "filelists": + assert.EqualValues(t, 257, d.Size) + assert.EqualValues(t, 326, d.OpenSize) + assert.Equal(t, "repodata/filelists.xml.gz", d.Location.Href) + case "other": + assert.EqualValues(t, 306, d.Size) + assert.EqualValues(t, 394, d.OpenSize) + assert.Equal(t, "repodata/other.xml.gz", d.Location.Href) + } + } + }) + + t.Run("repomd.xml.asc", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req = NewRequest(t, "GET", url+"/repomd.xml.asc") + resp := MakeRequest(t, req, http.StatusOK) + + assert.Contains(t, resp.Body.String(), "-----BEGIN PGP SIGNATURE-----") + }) + + decodeGzipXML := func(t testing.TB, resp *httptest.ResponseRecorder, v any) { + t.Helper() + + zr, err := gzip.NewReader(resp.Body) + require.NoError(t, err) + + require.NoError(t, xml.NewDecoder(zr).Decode(v)) + } + + t.Run("primary.xml.gz", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req = NewRequest(t, "GET", url+"/primary.xml.gz") + resp := MakeRequest(t, req, http.StatusOK) + + type EntryList struct { + Entries []*rpm_module.Entry `xml:"entry"` + } + + type Metadata struct { + XMLName xml.Name `xml:"metadata"` + Xmlns string `xml:"xmlns,attr"` + XmlnsRpm string `xml:"xmlns:rpm,attr"` + PackageCount int `xml:"packages,attr"` + Packages []struct { + XMLName xml.Name `xml:"package"` + Type string `xml:"type,attr"` + Name string `xml:"name"` + Architecture string `xml:"arch"` + Version struct { + Epoch string `xml:"epoch,attr"` + Version string `xml:"ver,attr"` + Release string `xml:"rel,attr"` + } `xml:"version"` + Checksum struct { + Checksum string `xml:",chardata"` + Type string `xml:"type,attr"` + Pkgid string `xml:"pkgid,attr"` + } `xml:"checksum"` + Summary string `xml:"summary"` + Description string `xml:"description"` + Packager string `xml:"packager"` + URL string `xml:"url"` + Time struct { + File uint64 `xml:"file,attr"` + Build uint64 `xml:"build,attr"` + } `xml:"time"` + Size struct { + Package int64 `xml:"package,attr"` + Installed uint64 `xml:"installed,attr"` + Archive uint64 `xml:"archive,attr"` + } `xml:"size"` + Location struct { + Href string `xml:"href,attr"` + } `xml:"location"` + Format struct { + License string `xml:"license"` + Vendor string `xml:"vendor"` + Group string `xml:"group"` + Buildhost string `xml:"buildhost"` + Sourcerpm string `xml:"sourcerpm"` + Provides EntryList `xml:"provides"` + Requires EntryList `xml:"requires"` + Conflicts EntryList `xml:"conflicts"` + Obsoletes EntryList `xml:"obsoletes"` + Files []*rpm_module.File `xml:"file"` + } `xml:"format"` + } `xml:"package"` + } + + var result Metadata + decodeGzipXML(t, resp, &result) + + assert.EqualValues(t, 1, result.PackageCount) + assert.Len(t, result.Packages, 1) + p := result.Packages[0] + assert.Equal(t, "rpm", p.Type) + assert.Equal(t, packageName, p.Name) + assert.Equal(t, packageArchitecture, p.Architecture) + assert.Equal(t, "YES", p.Checksum.Pkgid) + assert.Equal(t, "sha256", p.Checksum.Type) + assert.Equal(t, "f1d5d2ffcbe4a7568e98b864f40d923ecca084e9b9bcd5977ed6521c46d3fa4c", p.Checksum.Checksum) + assert.Equal(t, "https://gitea.io", p.URL) + assert.EqualValues(t, len(content), p.Size.Package) + assert.EqualValues(t, 13, p.Size.Installed) + assert.EqualValues(t, 272, p.Size.Archive) + assert.Equal(t, fmt.Sprintf("package/%s/%s/%s/%s", packageName, packageVersion, packageArchitecture, fmt.Sprintf("%s-%s.%s.rpm", packageName, packageVersion, packageArchitecture)), p.Location.Href) + f := p.Format + assert.Equal(t, "MIT", f.License) + assert.Len(t, f.Provides.Entries, 2) + assert.Len(t, f.Requires.Entries, 7) + assert.Empty(t, f.Conflicts.Entries) + assert.Empty(t, f.Obsoletes.Entries) + assert.Len(t, f.Files, 1) + }) + + t.Run("filelists.xml.gz", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req = NewRequest(t, "GET", url+"/filelists.xml.gz") + resp := MakeRequest(t, req, http.StatusOK) + + type Filelists struct { + XMLName xml.Name `xml:"filelists"` + Xmlns string `xml:"xmlns,attr"` + PackageCount int `xml:"packages,attr"` + Packages []struct { + Pkgid string `xml:"pkgid,attr"` + Name string `xml:"name,attr"` + Architecture string `xml:"arch,attr"` + Version struct { + Epoch string `xml:"epoch,attr"` + Version string `xml:"ver,attr"` + Release string `xml:"rel,attr"` + } `xml:"version"` + Files []*rpm_module.File `xml:"file"` + } `xml:"package"` + } + + var result Filelists + decodeGzipXML(t, resp, &result) + + assert.EqualValues(t, 1, result.PackageCount) + assert.Len(t, result.Packages, 1) + p := result.Packages[0] + assert.NotEmpty(t, p.Pkgid) + assert.Equal(t, packageName, p.Name) + assert.Equal(t, packageArchitecture, p.Architecture) + assert.Len(t, p.Files, 1) + f := p.Files[0] + assert.Equal(t, "/usr/local/bin/hello", f.Path) + }) + + t.Run("other.xml.gz", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req = NewRequest(t, "GET", url+"/other.xml.gz") + resp := MakeRequest(t, req, http.StatusOK) + + type Other struct { + XMLName xml.Name `xml:"otherdata"` + Xmlns string `xml:"xmlns,attr"` + PackageCount int `xml:"packages,attr"` + Packages []struct { + Pkgid string `xml:"pkgid,attr"` + Name string `xml:"name,attr"` + Architecture string `xml:"arch,attr"` + Version struct { + Epoch string `xml:"epoch,attr"` + Version string `xml:"ver,attr"` + Release string `xml:"rel,attr"` + } `xml:"version"` + Changelogs []*rpm_module.Changelog `xml:"changelog"` + } `xml:"package"` + } + + var result Other + decodeGzipXML(t, resp, &result) + + assert.EqualValues(t, 1, result.PackageCount) + assert.Len(t, result.Packages, 1) + p := result.Packages[0] + assert.NotEmpty(t, p.Pkgid) + assert.Equal(t, packageName, p.Name) + assert.Equal(t, packageArchitecture, p.Architecture) + assert.Len(t, p.Changelogs, 1) + c := p.Changelogs[0] + assert.Equal(t, "KN4CK3R <dummy@gitea.io>", c.Author) + assert.EqualValues(t, 1678276800, c.Date) + assert.Equal(t, "- Changelog message.", c.Text) + }) + }) + + t.Run("Delete", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeRpm) + require.NoError(t, err) + assert.Empty(t, pvs) + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("UploadSign", func(t *testing.T) { + url := groupURL + "/upload?sign=true" + req := NewRequestWithBody(t, "PUT", url, bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + gpgReq := NewRequest(t, "GET", rootURL+"/repository.key") + gpgResp := MakeRequest(t, gpgReq, http.StatusOK) + pub, err := openpgp.ReadArmoredKeyRing(gpgResp.Body) + require.NoError(t, err) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture)) + resp := MakeRequest(t, req, http.StatusOK) + + _, sigs, err := rpmutils.Verify(resp.Body, pub) + require.NoError(t, err) + require.NotEmpty(t, sigs) + + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + }) + }) + } +} diff --git a/tests/integration/api_packages_rubygems_test.go b/tests/integration/api_packages_rubygems_test.go new file mode 100644 index 0000000..eb3dc0e --- /dev/null +++ b/tests/integration/api_packages_rubygems_test.go @@ -0,0 +1,398 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "crypto/md5" + "crypto/sha256" + "encoding/base64" + "fmt" + "mime/multipart" + "net/http" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/packages/rubygems" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageRubyGems(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + packageName := "gitea" + packageVersion := "1.0.5" + packageFilename := "gitea-1.0.5.gem" + packageDependency := "runtime-dep:>= 1.2.0&< 2.0" + rubyRequirements := "ruby:>= 2.3.0" + sep := "---" + + gemContent, _ := base64.StdEncoding.DecodeString(`bWV0YWRhdGEuZ3oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAwMDA0NDQAMDAwMDAw +MAAwMDAwMDAwADAwMDAwMDAxMDQxADE0MTEwNzcyMzY2ADAxMzQ0MQAgMAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB1c3RhcgAwMHdoZWVsAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAd2hlZWwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwMDAwMDAwADAwMDAw +MDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf +iwgA9vQjYQID1VVNb9QwEL37V5he9pRsmlJAFlQckCoOXAriQIUix5nNmsYf2JOqKwS/nYmz2d3Q +qqCCKpFdadfjmfdm5nmcLMv4k9DXm6Wrv4BCcQ5GiPcelF5pJVE7y6w0IHirESS7hhDJJu4I+jhu +Mc53Tsd5kZ8y30lcuWAEH2KY7HHtQhQs4+cJkwwuwNdeB6JhtbaNDoLTL1MQsFJrqQnr8jNrJJJH +WZTHWfEiK094UYj0zYvp4Z9YAx5sA1ZpSCS3M30zeWwo2bG60FvUBjIKJts2GwMW76r0Yr9NzjN3 +YhwsGX2Ozl4dpcWwvK9d43PQtDIv9igvHwSyIIwFmXHjqTqxLY8MPkCADmQk80p2EfZ6VbM6/ue6 +/1D0Bq7/qeA/zh6W82leHmhFWUHn/JbsEfT6q7QbiCpoj8l0QcEUFLmX6kq2wBEiMjBSd+Pwt7T5 +Ot0kuXYMbkD1KOuOBnWYb7hBsAP4bhlkFRqnqpWefMZ/pHCn6+WIFGq2dgY8EQq+RvRRLJcTyZJ1 +WhHqGPTu7QdmACXdJFLwb9+ZdxErbSPKrqsMxJhAWCJ1qaqRdtu6yktcT/STsamG0qp7rsa5EL/K +MBua30uw4ynzExqYWRJDfx8/kQWN3PwsDh2jYLr1W+pZcAmCs9splvnz/Flesqhbq21bXcGG/OLh ++2fv/JTF3hgZyCW9OaZjxoZjdnBGfgKpxZyJ1QYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGF0 +YS50YXIuZ3oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAwMDA0NDQAMDAwMDAwMAAw +MDAwMDAwADAwMDAwMDAwMjQyADE0MTEwNzcyMzY2ADAxMzM2MQAgMAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB1c3RhcgAwMHdoZWVsAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAd2hlZWwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwMDAwMDAwADAwMDAwMDAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfiwgA +9vQjYQID7M/NCsMgDABgz32KrA/QxersK/Q17ExXIcyhlr7+HLv1sJ02KPhBCPk5JOyn881nsl2c +xI+gRDRaC3zbZ8RBCamlxGHolTFlX11kLwDFH6wp21hO2RYi/rD3bb5/7iCubFOCMbBtABzNkIjn +bvGlAnisOUE7EnOALUR2p7b06e6aV4iqqqrquJ4AAAD//wMA+sA/NQAIAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGNoZWNr +c3Vtcy55YW1sLmd6AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwMDAwNDQ0ADAwMDAwMDAAMDAw +MDAwMAAwMDAwMDAwMDQ1MAAxNDExMDc3MjM2NgAwMTQ2MTIAIDAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAdXN0YXIAMDB3aGVlbAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAHdoZWVsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDAwMDAwMAAwMDAwMDAwAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH4sIAPb0 +I2ECA2WQOa4UQAxE8znFXGCQ21vbPyMj5wRuL0Qk6EecnmZCyKyy9FSvXq/X4/u3ryj68Xg+f/Zn +VHzGlx+/P57qvU4XxWalBKftSXOgCjNYkdRycrC5Axem+W4HqS12PNEv7836jF9vnlHxwSyxKY+y +go0cPblyHzkrZ4HF1GSVhe7mOOoasXNk2fnbUxb+19Pp9tobD/QlJKMX7y204PREh6nQ5hG9Alw6 +x4TnmtA+aekGfm6wAseog2LSgpR4Q7cYnAH3K4qAQa6A6JCC1gpuY7P+9YxE5SZ+j0eVGbaBTwBQ +iIqRUyyzLCoFCBdYNWxniapTavD97blXTzFvgoVoAsKBAtlU48cdaOmeZDpwV01OtcGwjscfeUrY +B9QBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`) + checksum := fmt.Sprintf("%x", sha256.Sum256(gemContent)) + + holaPackageName := "hola" + holaPackageVersion := "0.0.1" + // holaPackageFilename := "hola-0.0.1.gem" + holaPackageDependency := "example:~> 1.1&>= 1.1.4,zero:>= 0" + holaRubyGemsRequirements := "rubygems:= 1.2.3" + // sep := "---" + + holaGemContent, _ := base64.StdEncoding.DecodeString(`bWV0YWRhdGEuZ3oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAwMDA0NDQAMDAwMDAw +MAAwMDAwMDAwADAwMDAwMDAwNzYyADE0NjIyMjU1MzY0ADAxMzQ1NAAgMAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB1c3RhcgAwMHdoZWVsAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAd2hlZWwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwMDAwMDAwADAwMDAw +MDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf +iwgA9FpJZgID1VVNb9QwEL37V7i95JQ02W0PWGpPSHBCAiQOIBRNnNmNqT+C7aBdEPx2xtnNbtNW +BVokRBLJ8Xhm/Oa9eJLnOT/xQ7M9c80nlFG8QCPE2x6lWikJUTnLLBgUvHMa2Bf0gUzinph3uyXG ++cGpLMqiYr2GuHLeCJ5iGAyxcz4IlvNXSl7z1wN4sNGlBefx86A8CtYo2yovOI1Moo+17EBRyg8f +WQuR4CzKxXleXuTVM16WYnyKcrr4e9Zij7ZFKxWOe90F/Hzy2BLmXY24AdNrpPkeiEEb7yv2zXGZ +nGfutFuy5HSf/rg6HSdp+hBju+vAW1YVVXbMcnX5qCyUpDgnc9z2VJrwg43KpNp6jx41QiDzCnTA +o2b1rJD/uvDflPwrevfX9H4k4KzM/pFOTwDcYpBe9alDCIYGlKZhg3KI0Gg6c+mo4iaiTSGHqYfa +t07WKzX57N5ILq2as9RkCt+wzhnsYU2NQCtJKfa+BiPQ8QfBv31nvQuxVjZE0Lo2GMLoP2Z3I6xd +zL7yuofYTftMxrZONdcPdLU5j7dZnHH4awZn/M0grNGEp8HILrM/RVEVi2LJ5l9SIuwOnmWxLBYX +LKi1VXZdX+NWsHDzF3HDlYXBGPBbwV+SlicsIql0VPsn64DO03AGAAAAAAAAAAAAAAAAAAAAAGRh +dGEudGFyLmd6AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwMDAwNDQ0ADAwMDAwMDAA +MDAwMDAwMAAwMDAwMDAwMDE1MQAxNDYyMjI1NTM2NAAwMTMzNjIAIDAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAdXN0YXIAMDB3aGVlbAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAHdoZWVsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDAwMDAwMAAwMDAwMDAw +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH4sI +APRaSWYCA8rJTNLPyM9J1CtKYqAVMDA0MDAzMWEwgAB0Gsw2NDEzMjIyNTU2A6ozNDY2NmFQMGCg +AygtLkksAjqlPCM1NQePOkLy6J4bBaNgFIyCQQ4AAAAA//8DAMJiTFMABgAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABjaGVj +a3N1bXMueWFtbC5negAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDAwMDQ0NAAwMDAwMDAwADAw +MDAwMDAAMDAwMDAwMDA0NTMAMTQ2MjIyNTUzNjQAMDE0NjE3ACAwAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHVzdGFyADAwd2hlZWwAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAB3aGVlbAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAwMDAwMDAAMDAwMDAwMAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB+LCAD0 +WklmAgNlkD2uFDAMBvs9xV5gke04/nkdHT0nsGObigZtxekJr4QijaWMZr7X6/X4/u0rbfl4PJ8/ ++x0V7/jy4/fH0xIRx4PkkBT7Qt8cG0DSNq2iBIkMwD0ETCCgQ6Xh5WZWeHmfrHf8+uSRBivatKy8 +n6UT249x1CbVnAEHsbSDNEJAfRDuOytsa27767mR/vPkPtXpakdXyRBdsXti1lkjiye7uCeyHath +7VzMRxAOxNo3L31AiJu7ryPe7BsZR91Tcp21chYaSyvDwZaQE+SxlOn09fqnM8nUVcUb5CSEbljA +LH4HrZhC5YJMNyRevKZtKiqTZroEkKvuoHmS0iYWQFXYnTqOz0Wpxqw6RqnHhf0uah45WkjqtfPx +B6h0MiLUAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==`) + holaChecksum := fmt.Sprintf("%x", sha256.Sum256(holaGemContent)) + + root := fmt.Sprintf("/api/packages/%s/rubygems", user.Name) + + uploadFile := func(t *testing.T, content []byte, expectedStatus int) { + req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/api/v1/gems", root), bytes.NewReader(content)). + AddBasicAuth(user.Name) + MakeRequest(t, req, expectedStatus) + } + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadFile(t, gemContent, http.StatusCreated) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeRubyGems) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.NotNil(t, pd.SemVer) + assert.IsType(t, &rubygems.Metadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, packageFilename, pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + require.NoError(t, err) + assert.Equal(t, int64(4608), pb.Size) + }) + + t.Run("UploadExists", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadFile(t, gemContent, http.StatusConflict) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/gems/%s", root, packageFilename)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, gemContent, resp.Body.Bytes()) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeRubyGems) + require.NoError(t, err) + assert.Len(t, pvs, 1) + assert.Equal(t, int64(1), pvs[0].DownloadCount) + }) + + t.Run("DownloadGemspec", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/quick/Marshal.4.8/%sspec.rz", root, packageFilename)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + b, _ := base64.StdEncoding.DecodeString(`eJxi4Si1EndPzbWyCi5ITc5My0xOLMnMz2M8zMIRLeGpxGWsZ6RnzGbF5hqSyempxJWeWZKayGbN +EBJqJQjWFZZaVJyZnxfN5qnEZahnoGcKkjTwVBJyB6lUKEhMzk5MTwULGngqcRaVJlWCONEMBp5K +DGAWSKc7zFhPJamg0qRK99TcYphehZLU4hKInFhGSUlBsZW+PtgZepn5+iDxECRzDUDGcfh6hoA4 +gAAAAP//MS06Gw==`) + assert.Equal(t, b, resp.Body.Bytes()) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeRubyGems) + require.NoError(t, err) + assert.Len(t, pvs, 1) + assert.Equal(t, int64(1), pvs[0].DownloadCount) + }) + + t.Run("EnumeratePackages", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + enumeratePackages := func(t *testing.T, endpoint string, expectedContent []byte) { + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s", root, endpoint)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, expectedContent, resp.Body.Bytes()) + } + + b, _ := base64.StdEncoding.DecodeString(`H4sICAAAAAAA/3NwZWNzLjQuOABi4Yhmi+bwVOJKzyxJTWSzYnMNCbUSdE/NtbIKSy0qzszPi2bzVOIy1DPQM2WzZgjxVOIsKk2qBDEBAQAA///xOEYKOwAAAA==`) + enumeratePackages(t, "specs.4.8.gz", b) + b, _ = base64.StdEncoding.DecodeString(`H4sICAAAAAAA/2xhdGVzdF9zcGVjcy40LjgAYuGIZovm8FTiSs8sSU1ks2JzDQm1EnRPzbWyCkstKs7Mz4tm81TiMtQz0DNls2YI8VTiLCpNqgQxAQEAAP//8ThGCjsAAAA=`) + enumeratePackages(t, "latest_specs.4.8.gz", b) + b, _ = base64.StdEncoding.DecodeString(`H4sICAAAAAAA/3ByZXJlbGVhc2Vfc3BlY3MuNC44AGLhiGYABAAA//9snXr5BAAAAA==`) + enumeratePackages(t, "prerelease_specs.4.8.gz", b) + }) + + t.Run("UploadHola", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadFile(t, holaGemContent, http.StatusCreated) + }) + + t.Run("PackageInfo", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/info/%s", root, packageName)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + expected := fmt.Sprintf("%s\n%s %s|checksum:%s,%s\n", + sep, packageVersion, packageDependency, checksum, rubyRequirements) + assert.Equal(t, expected, resp.Body.String()) + }) + + t.Run("HolaPackageInfo", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/info/%s", root, holaPackageName)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + expected := fmt.Sprintf("%s\n%s %s|checksum:%s,%s\n", + sep, holaPackageVersion, holaPackageDependency, holaChecksum, holaRubyGemsRequirements) + assert.Equal(t, expected, resp.Body.String()) + }) + t.Run("Versions", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + versionsReq := NewRequest(t, "GET", fmt.Sprintf("%s/versions", root)). + AddBasicAuth(user.Name) + versionsResp := MakeRequest(t, versionsReq, http.StatusOK) + infoReq := NewRequest(t, "GET", fmt.Sprintf("%s/info/%s", root, packageName)). + AddBasicAuth(user.Name) + infoResp := MakeRequest(t, infoReq, http.StatusOK) + holaInfoReq := NewRequest(t, "GET", fmt.Sprintf("%s/info/%s", root, holaPackageName)). + AddBasicAuth(user.Name) + holaInfoResp := MakeRequest(t, holaInfoReq, http.StatusOK) + + // expected := fmt.Sprintf("%s\n%s %s %x\n", + // sep, packageName, packageVersion, md5.Sum(infoResp.Body.Bytes())) + lines := versionsResp.Body.String() + assert.ElementsMatch(t, strings.Split(lines, "\n"), []string{ + sep, + fmt.Sprintf("%s %s %x", packageName, packageVersion, md5.Sum(infoResp.Body.Bytes())), + fmt.Sprintf("%s %s %x", holaPackageName, holaPackageVersion, md5.Sum(holaInfoResp.Body.Bytes())), + "", + }) + }) + + t.Run("DeleteHola", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + body := bytes.Buffer{} + writer := multipart.NewWriter(&body) + writer.WriteField("gem_name", holaPackageName) + writer.WriteField("version", holaPackageVersion) + writer.Close() + + req := NewRequestWithBody(t, "DELETE", fmt.Sprintf("%s/api/v1/gems/yank", root), &body). + SetHeader("Content-Type", writer.FormDataContentType()). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("Delete", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + body := bytes.Buffer{} + writer := multipart.NewWriter(&body) + writer.WriteField("gem_name", packageName) + writer.WriteField("version", packageVersion) + writer.Close() + + req := NewRequestWithBody(t, "DELETE", fmt.Sprintf("%s/api/v1/gems/yank", root), &body). + SetHeader("Content-Type", writer.FormDataContentType()). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeRubyGems) + require.NoError(t, err) + assert.Empty(t, pvs) + }) + + t.Run("NonExistingGem", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/info/%s", root, packageName)). + AddBasicAuth(user.Name) + _ = MakeRequest(t, req, http.StatusNotFound) + }) + t.Run("EmptyVersions", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/versions", root)). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, sep+"\n", resp.Body.String()) + }) +} diff --git a/tests/integration/api_packages_swift_test.go b/tests/integration/api_packages_swift_test.go new file mode 100644 index 0000000..5b6229f --- /dev/null +++ b/tests/integration/api_packages_swift_test.go @@ -0,0 +1,327 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + swift_module "code.gitea.io/gitea/modules/packages/swift" + "code.gitea.io/gitea/modules/setting" + swift_router "code.gitea.io/gitea/routers/api/packages/swift" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageSwift(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + packageScope := "test-scope" + packageName := "test_package" + packageID := packageScope + "." + packageName + packageVersion := "1.0.3" + packageAuthor := "KN4CK3R" + packageDescription := "Gitea Test Package" + packageRepositoryURL := "https://gitea.io/gitea/gitea" + contentManifest1 := "// swift-tools-version:5.7\n//\n// Package.swift" + contentManifest2 := "// swift-tools-version:5.6\n//\n// Package@swift-5.6.swift" + + url := fmt.Sprintf("/api/packages/%s/swift", user.Name) + + t.Run("CheckAcceptMediaType", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + for _, sub := range []string{ + "/scope/package", + "/scope/package.json", + "/scope/package/1.0.0", + "/scope/package/1.0.0.json", + "/scope/package/1.0.0.zip", + "/scope/package/1.0.0/Package.swift", + "/identifiers", + } { + req := NewRequest(t, "GET", url+sub) + req.Header.Add("Accept", "application/unknown") + resp := MakeRequest(t, req, http.StatusBadRequest) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, "application/problem+json", resp.Header().Get("Content-Type")) + } + + req := NewRequestWithBody(t, "PUT", url+"/scope/package/1.0.0", strings.NewReader("")). + AddBasicAuth(user.Name). + SetHeader("Accept", "application/unknown") + resp := MakeRequest(t, req, http.StatusBadRequest) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, "application/problem+json", resp.Header().Get("Content-Type")) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + uploadPackage := func(t *testing.T, url string, expectedStatus int, sr io.Reader, metadata string) { + var body bytes.Buffer + mpw := multipart.NewWriter(&body) + + part, _ := mpw.CreateFormFile("source-archive", "source-archive.zip") + io.Copy(part, sr) + + if metadata != "" { + mpw.WriteField("metadata", metadata) + } + + mpw.Close() + + req := NewRequestWithBody(t, "PUT", url, &body). + SetHeader("Content-Type", mpw.FormDataContentType()). + SetHeader("Accept", swift_router.AcceptJSON). + AddBasicAuth(user.Name) + MakeRequest(t, req, expectedStatus) + } + + createArchive := func(files map[string]string) *bytes.Buffer { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for filename, content := range files { + w, _ := zw.Create(filename) + w.Write([]byte(content)) + } + zw.Close() + return &buf + } + + for _, triple := range []string{"/sc_ope/package/1.0.0", "/scope/pack~age/1.0.0", "/scope/package/1_0.0"} { + req := NewRequestWithBody(t, "PUT", url+triple, bytes.NewReader([]byte{})). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusBadRequest) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, "application/problem+json", resp.Header().Get("Content-Type")) + } + + uploadURL := fmt.Sprintf("%s/%s/%s/%s", url, packageScope, packageName, packageVersion) + + req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})) + MakeRequest(t, req, http.StatusUnauthorized) + + uploadPackage( + t, + uploadURL, + http.StatusCreated, + createArchive(map[string]string{ + "Package.swift": contentManifest1, + "Package@swift-5.6.swift": contentManifest2, + }), + `{"name":"`+packageName+`","version":"`+packageVersion+`","description":"`+packageDescription+`","codeRepository":"`+packageRepositoryURL+`","author":{"givenName":"`+packageAuthor+`"},"repositoryURLs":["`+packageRepositoryURL+`"]}`, + ) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeSwift) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.NotNil(t, pd.SemVer) + assert.Equal(t, packageID, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + assert.IsType(t, &swift_module.Metadata{}, pd.Metadata) + metadata := pd.Metadata.(*swift_module.Metadata) + assert.Equal(t, packageDescription, metadata.Description) + assert.Len(t, metadata.Manifests, 2) + assert.Equal(t, contentManifest1, metadata.Manifests[""].Content) + assert.Equal(t, contentManifest2, metadata.Manifests["5.6"].Content) + assert.Len(t, pd.VersionProperties, 1) + assert.Equal(t, packageRepositoryURL, pd.VersionProperties.GetByName(swift_module.PropertyRepositoryURL)) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, fmt.Sprintf("%s-%s.zip", packageName, packageVersion), pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + uploadPackage( + t, + uploadURL, + http.StatusConflict, + createArchive(map[string]string{ + "Package.swift": contentManifest1, + }), + "", + ) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/%s.zip", url, packageScope, packageName, packageVersion)). + AddBasicAuth(user.Name). + SetHeader("Accept", swift_router.AcceptZip) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, "application/zip", resp.Header().Get("Content-Type")) + + pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeSwift, packageID, packageVersion) + assert.NotNil(t, pv) + require.NoError(t, err) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pv) + require.NoError(t, err) + assert.Equal(t, "sha256="+pd.Files[0].Blob.HashSHA256, resp.Header().Get("Digest")) + }) + + t.Run("EnumeratePackageVersions", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s", url, packageScope, packageName)). + AddBasicAuth(user.Name). + SetHeader("Accept", swift_router.AcceptJSON) + resp := MakeRequest(t, req, http.StatusOK) + + versionURL := setting.AppURL + url[1:] + fmt.Sprintf("/%s/%s/%s", packageScope, packageName, packageVersion) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, fmt.Sprintf(`<%s>; rel="latest-version"`, versionURL), resp.Header().Get("Link")) + + body := resp.Body.String() + + var result *swift_router.EnumeratePackageVersionsResponse + DecodeJSON(t, resp, &result) + + assert.Len(t, result.Releases, 1) + assert.Contains(t, result.Releases, packageVersion) + assert.Equal(t, versionURL, result.Releases[packageVersion].URL) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s.json", url, packageScope, packageName)). + AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, body, resp.Body.String()) + }) + + t.Run("PackageVersionMetadata", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/%s", url, packageScope, packageName, packageVersion)). + AddBasicAuth(user.Name). + SetHeader("Accept", swift_router.AcceptJSON) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + + body := resp.Body.String() + + var result *swift_router.PackageVersionMetadataResponse + DecodeJSON(t, resp, &result) + + pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeSwift, packageID, packageVersion) + assert.NotNil(t, pv) + require.NoError(t, err) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pv) + require.NoError(t, err) + + assert.Equal(t, packageID, result.ID) + assert.Equal(t, packageVersion, result.Version) + assert.Len(t, result.Resources, 1) + assert.Equal(t, "source-archive", result.Resources[0].Name) + assert.Equal(t, "application/zip", result.Resources[0].Type) + assert.Equal(t, pd.Files[0].Blob.HashSHA256, result.Resources[0].Checksum) + assert.Equal(t, "SoftwareSourceCode", result.Metadata.Type) + assert.Equal(t, packageName, result.Metadata.Name) + assert.Equal(t, packageVersion, result.Metadata.Version) + assert.Equal(t, packageDescription, result.Metadata.Description) + assert.Equal(t, "Swift", result.Metadata.ProgrammingLanguage.Name) + assert.Equal(t, packageAuthor, result.Metadata.Author.GivenName) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/%s.json", url, packageScope, packageName, packageVersion)). + AddBasicAuth(user.Name) + resp = MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, body, resp.Body.String()) + }) + + t.Run("DownloadManifest", func(t *testing.T) { + manifestURL := fmt.Sprintf("%s/%s/%s/%s/Package.swift", url, packageScope, packageName, packageVersion) + + t.Run("Default", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", manifestURL). + AddBasicAuth(user.Name). + SetHeader("Accept", swift_router.AcceptSwift) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, "text/x-swift", resp.Header().Get("Content-Type")) + assert.Equal(t, contentManifest1, resp.Body.String()) + }) + + t.Run("DifferentVersion", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", manifestURL+"?swift-version=5.6"). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, "text/x-swift", resp.Header().Get("Content-Type")) + assert.Equal(t, contentManifest2, resp.Body.String()) + + req = NewRequest(t, "GET", manifestURL+"?swift-version=5.6.0"). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("Redirect", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", manifestURL+"?swift-version=1.0"). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusSeeOther) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, setting.AppURL+url[1:]+fmt.Sprintf("/%s/%s/%s/Package.swift", packageScope, packageName, packageVersion), resp.Header().Get("Location")) + }) + }) + + t.Run("LookupPackageIdentifiers", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", url+"/identifiers"). + SetHeader("Accept", swift_router.AcceptJSON) + resp := MakeRequest(t, req, http.StatusBadRequest) + + assert.Equal(t, "1", resp.Header().Get("Content-Version")) + assert.Equal(t, "application/problem+json", resp.Header().Get("Content-Type")) + + req = NewRequest(t, "GET", url+"/identifiers?url=https://unknown.host/") + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", url+"/identifiers?url="+packageRepositoryURL). + SetHeader("Accept", swift_router.AcceptJSON) + resp = MakeRequest(t, req, http.StatusOK) + + var result *swift_router.LookupPackageIdentifiersResponse + DecodeJSON(t, resp, &result) + + assert.Len(t, result.Identifiers, 1) + assert.Equal(t, packageID, result.Identifiers[0]) + }) +} diff --git a/tests/integration/api_packages_test.go b/tests/integration/api_packages_test.go new file mode 100644 index 0000000..27aed0f --- /dev/null +++ b/tests/integration/api_packages_test.go @@ -0,0 +1,647 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "crypto/sha256" + "fmt" + "net/http" + "strings" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + container_model "code.gitea.io/gitea/models/packages/container" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + packages_service "code.gitea.io/gitea/services/packages" + packages_cleanup_service "code.gitea.io/gitea/services/packages/cleanup" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageAPI(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + session := loginUser(t, user.Name) + tokenReadPackage := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadPackage) + tokenDeletePackage := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWritePackage) + + packageName := "test-package" + packageVersion := "1.0.3" + filename := "file.bin" + + url := fmt.Sprintf("/api/packages/%s/generic/%s/%s/%s", user.Name, packageName, packageVersion, filename) + req := NewRequestWithBody(t, "PUT", url, bytes.NewReader([]byte{})). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + t.Run("ListPackages", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s", user.Name)). + AddTokenAuth(tokenReadPackage) + resp := MakeRequest(t, req, http.StatusOK) + + var apiPackages []*api.Package + DecodeJSON(t, resp, &apiPackages) + + assert.Len(t, apiPackages, 1) + assert.Equal(t, string(packages_model.TypeGeneric), apiPackages[0].Type) + assert.Equal(t, packageName, apiPackages[0].Name) + assert.Equal(t, packageVersion, apiPackages[0].Version) + assert.NotNil(t, apiPackages[0].Creator) + assert.Equal(t, user.Name, apiPackages[0].Creator.UserName) + }) + + t.Run("GetPackage", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/dummy/%s/%s", user.Name, packageName, packageVersion)). + AddTokenAuth(tokenReadPackage) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)). + AddTokenAuth(tokenReadPackage) + resp := MakeRequest(t, req, http.StatusOK) + + var p *api.Package + DecodeJSON(t, resp, &p) + + assert.Equal(t, string(packages_model.TypeGeneric), p.Type) + assert.Equal(t, packageName, p.Name) + assert.Equal(t, packageVersion, p.Version) + assert.NotNil(t, p.Creator) + assert.Equal(t, user.Name, p.Creator.UserName) + + t.Run("RepositoryLink", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + p, err := packages_model.GetPackageByName(db.DefaultContext, user.ID, packages_model.TypeGeneric, packageName) + require.NoError(t, err) + + // no repository link + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)). + AddTokenAuth(tokenReadPackage) + resp := MakeRequest(t, req, http.StatusOK) + + var ap1 *api.Package + DecodeJSON(t, resp, &ap1) + assert.Nil(t, ap1.Repository) + + // link to public repository + require.NoError(t, packages_model.SetRepositoryLink(db.DefaultContext, p.ID, 1)) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)). + AddTokenAuth(tokenReadPackage) + resp = MakeRequest(t, req, http.StatusOK) + + var ap2 *api.Package + DecodeJSON(t, resp, &ap2) + assert.NotNil(t, ap2.Repository) + assert.EqualValues(t, 1, ap2.Repository.ID) + + // link to private repository + require.NoError(t, packages_model.SetRepositoryLink(db.DefaultContext, p.ID, 2)) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)). + AddTokenAuth(tokenReadPackage) + resp = MakeRequest(t, req, http.StatusOK) + + var ap3 *api.Package + DecodeJSON(t, resp, &ap3) + assert.Nil(t, ap3.Repository) + + require.NoError(t, packages_model.UnlinkRepositoryFromAllPackages(db.DefaultContext, 2)) + }) + }) + + t.Run("ListPackageFiles", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/dummy/%s/%s/files", user.Name, packageName, packageVersion)). + AddTokenAuth(tokenReadPackage) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s/files", user.Name, packageName, packageVersion)). + AddTokenAuth(tokenReadPackage) + resp := MakeRequest(t, req, http.StatusOK) + + var files []*api.PackageFile + DecodeJSON(t, resp, &files) + + assert.Len(t, files, 1) + assert.Equal(t, int64(0), files[0].Size) + assert.Equal(t, filename, files[0].Name) + assert.Equal(t, "d41d8cd98f00b204e9800998ecf8427e", files[0].HashMD5) + assert.Equal(t, "da39a3ee5e6b4b0d3255bfef95601890afd80709", files[0].HashSHA1) + assert.Equal(t, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", files[0].HashSHA256) + assert.Equal(t, "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", files[0].HashSHA512) + }) + + t.Run("DeletePackage", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/packages/%s/dummy/%s/%s", user.Name, packageName, packageVersion)). + AddTokenAuth(tokenDeletePackage) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)). + AddTokenAuth(tokenDeletePackage) + MakeRequest(t, req, http.StatusNoContent) + }) +} + +func TestPackageAccess(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + inactive := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 9}) + limitedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 33}) + privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 31}) + privateOrgMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23}) // user has package write access + limitedOrgMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 36}) // user has package write access + publicOrgMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 25}) // user has package read access + privateOrgNoMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 35}) + limitedOrgNoMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 22}) + publicOrgNoMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17}) + + uploadPackage := func(doer, owner *user_model.User, filename string, expectedStatus int) { + url := fmt.Sprintf("/api/packages/%s/generic/test-package/1.0/%s.bin", owner.Name, filename) + req := NewRequestWithBody(t, "PUT", url, bytes.NewReader([]byte{1})) + if doer != nil { + req.AddBasicAuth(doer.Name) + } + MakeRequest(t, req, expectedStatus) + } + + downloadPackage := func(doer, owner *user_model.User, expectedStatus int) { + url := fmt.Sprintf("/api/packages/%s/generic/test-package/1.0/admin.bin", owner.Name) + req := NewRequest(t, "GET", url) + if doer != nil { + req.AddBasicAuth(doer.Name) + } + MakeRequest(t, req, expectedStatus) + } + + type Target struct { + Owner *user_model.User + ExpectedStatus int + } + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + cases := []struct { + Doer *user_model.User + Filename string + Targets []Target + }{ + { // Admins can upload to every owner + Doer: admin, + Filename: "admin", + Targets: []Target{ + {admin, http.StatusCreated}, + {inactive, http.StatusCreated}, + {user, http.StatusCreated}, + {limitedUser, http.StatusCreated}, + {privateUser, http.StatusCreated}, + {privateOrgMember, http.StatusCreated}, + {limitedOrgMember, http.StatusCreated}, + {publicOrgMember, http.StatusCreated}, + {privateOrgNoMember, http.StatusCreated}, + {limitedOrgNoMember, http.StatusCreated}, + {publicOrgNoMember, http.StatusCreated}, + }, + }, + { // Without credentials no upload should be possible + Doer: nil, + Filename: "nil", + Targets: []Target{ + {admin, http.StatusUnauthorized}, + {inactive, http.StatusUnauthorized}, + {user, http.StatusUnauthorized}, + {limitedUser, http.StatusUnauthorized}, + {privateUser, http.StatusUnauthorized}, + {privateOrgMember, http.StatusUnauthorized}, + {limitedOrgMember, http.StatusUnauthorized}, + {publicOrgMember, http.StatusUnauthorized}, + {privateOrgNoMember, http.StatusUnauthorized}, + {limitedOrgNoMember, http.StatusUnauthorized}, + {publicOrgNoMember, http.StatusUnauthorized}, + }, + }, + { // Inactive users can't upload anywhere + Doer: inactive, + Filename: "inactive", + Targets: []Target{ + {admin, http.StatusUnauthorized}, + {inactive, http.StatusUnauthorized}, + {user, http.StatusUnauthorized}, + {limitedUser, http.StatusUnauthorized}, + {privateUser, http.StatusUnauthorized}, + {privateOrgMember, http.StatusUnauthorized}, + {limitedOrgMember, http.StatusUnauthorized}, + {publicOrgMember, http.StatusUnauthorized}, + {privateOrgNoMember, http.StatusUnauthorized}, + {limitedOrgNoMember, http.StatusUnauthorized}, + {publicOrgNoMember, http.StatusUnauthorized}, + }, + }, + { // Normal users can upload to self and orgs in which they are members and have package write access + Doer: user, + Filename: "user", + Targets: []Target{ + {admin, http.StatusUnauthorized}, + {inactive, http.StatusUnauthorized}, + {user, http.StatusCreated}, + {limitedUser, http.StatusUnauthorized}, + {privateUser, http.StatusUnauthorized}, + {privateOrgMember, http.StatusCreated}, + {limitedOrgMember, http.StatusCreated}, + {publicOrgMember, http.StatusUnauthorized}, + {privateOrgNoMember, http.StatusUnauthorized}, + {limitedOrgNoMember, http.StatusUnauthorized}, + {publicOrgNoMember, http.StatusUnauthorized}, + }, + }, + } + + for _, c := range cases { + for _, t := range c.Targets { + uploadPackage(c.Doer, t.Owner, c.Filename, t.ExpectedStatus) + } + } + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + cases := []struct { + Doer *user_model.User + Filename string + Targets []Target + }{ + { // Admins can access everything + Doer: admin, + Targets: []Target{ + {admin, http.StatusOK}, + {inactive, http.StatusOK}, + {user, http.StatusOK}, + {limitedUser, http.StatusOK}, + {privateUser, http.StatusOK}, + {privateOrgMember, http.StatusOK}, + {limitedOrgMember, http.StatusOK}, + {publicOrgMember, http.StatusOK}, + {privateOrgNoMember, http.StatusOK}, + {limitedOrgNoMember, http.StatusOK}, + {publicOrgNoMember, http.StatusOK}, + }, + }, + { // Without credentials only public owners are accessible + Doer: nil, + Targets: []Target{ + {admin, http.StatusOK}, + {inactive, http.StatusOK}, + {user, http.StatusOK}, + {limitedUser, http.StatusUnauthorized}, + {privateUser, http.StatusUnauthorized}, + {privateOrgMember, http.StatusUnauthorized}, + {limitedOrgMember, http.StatusUnauthorized}, + {publicOrgMember, http.StatusOK}, + {privateOrgNoMember, http.StatusUnauthorized}, + {limitedOrgNoMember, http.StatusUnauthorized}, + {publicOrgNoMember, http.StatusOK}, + }, + }, + { // Inactive users have no access + Doer: inactive, + Targets: []Target{ + {admin, http.StatusUnauthorized}, + {inactive, http.StatusUnauthorized}, + {user, http.StatusUnauthorized}, + {limitedUser, http.StatusUnauthorized}, + {privateUser, http.StatusUnauthorized}, + {privateOrgMember, http.StatusUnauthorized}, + {limitedOrgMember, http.StatusUnauthorized}, + {publicOrgMember, http.StatusUnauthorized}, + {privateOrgNoMember, http.StatusUnauthorized}, + {limitedOrgNoMember, http.StatusUnauthorized}, + {publicOrgNoMember, http.StatusUnauthorized}, + }, + }, + { // Normal users can access self, public or limited users/orgs and private orgs in which they are members + Doer: user, + Targets: []Target{ + {admin, http.StatusOK}, + {inactive, http.StatusOK}, + {user, http.StatusOK}, + {limitedUser, http.StatusOK}, + {privateUser, http.StatusUnauthorized}, + {privateOrgMember, http.StatusOK}, + {limitedOrgMember, http.StatusOK}, + {publicOrgMember, http.StatusOK}, + {privateOrgNoMember, http.StatusUnauthorized}, + {limitedOrgNoMember, http.StatusOK}, + {publicOrgNoMember, http.StatusOK}, + }, + }, + } + + for _, c := range cases { + for _, target := range c.Targets { + downloadPackage(c.Doer, target.Owner, target.ExpectedStatus) + } + } + }) + + t.Run("API", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, user.Name) + tokenReadPackage := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadPackage) + + for _, target := range []Target{ + {admin, http.StatusOK}, + {inactive, http.StatusOK}, + {user, http.StatusOK}, + {limitedUser, http.StatusOK}, + {privateUser, http.StatusForbidden}, + {privateOrgMember, http.StatusOK}, + {limitedOrgMember, http.StatusOK}, + {publicOrgMember, http.StatusOK}, + {privateOrgNoMember, http.StatusForbidden}, + {limitedOrgNoMember, http.StatusOK}, + {publicOrgNoMember, http.StatusOK}, + } { + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s", target.Owner.Name)). + AddTokenAuth(tokenReadPackage) + MakeRequest(t, req, target.ExpectedStatus) + } + }) +} + +func TestPackageQuota(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + limitTotalOwnerCount, limitTotalOwnerSize := setting.Packages.LimitTotalOwnerCount, setting.Packages.LimitTotalOwnerSize + + // Exceeded quota result in StatusForbidden for normal users but admins are always allowed to upload. + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10}) + + t.Run("Common", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + limitSizeGeneric := setting.Packages.LimitSizeGeneric + + uploadPackage := func(doer *user_model.User, version string, expectedStatus int) { + url := fmt.Sprintf("/api/packages/%s/generic/test-package/%s/file.bin", user.Name, version) + req := NewRequestWithBody(t, "PUT", url, bytes.NewReader([]byte{1})). + AddBasicAuth(doer.Name) + MakeRequest(t, req, expectedStatus) + } + + setting.Packages.LimitTotalOwnerCount = 0 + uploadPackage(user, "1.0", http.StatusForbidden) + uploadPackage(admin, "1.0", http.StatusCreated) + setting.Packages.LimitTotalOwnerCount = limitTotalOwnerCount + + setting.Packages.LimitTotalOwnerSize = 0 + uploadPackage(user, "1.1", http.StatusForbidden) + uploadPackage(admin, "1.1", http.StatusCreated) + setting.Packages.LimitTotalOwnerSize = limitTotalOwnerSize + + setting.Packages.LimitSizeGeneric = 0 + uploadPackage(user, "1.2", http.StatusForbidden) + uploadPackage(admin, "1.2", http.StatusCreated) + setting.Packages.LimitSizeGeneric = limitSizeGeneric + }) + + t.Run("Container", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + limitSizeContainer := setting.Packages.LimitSizeContainer + + uploadBlob := func(doer *user_model.User, data string, expectedStatus int) { + url := fmt.Sprintf("/v2/%s/quota-test/blobs/uploads?digest=sha256:%x", user.Name, sha256.Sum256([]byte(data))) + req := NewRequestWithBody(t, "POST", url, strings.NewReader(data)). + AddBasicAuth(doer.Name) + MakeRequest(t, req, expectedStatus) + } + + setting.Packages.LimitTotalOwnerSize = 0 + uploadBlob(user, "2", http.StatusForbidden) + uploadBlob(admin, "2", http.StatusCreated) + setting.Packages.LimitTotalOwnerSize = limitTotalOwnerSize + + setting.Packages.LimitSizeContainer = 0 + uploadBlob(user, "3", http.StatusForbidden) + uploadBlob(admin, "3", http.StatusCreated) + setting.Packages.LimitSizeContainer = limitSizeContainer + }) +} + +func TestPackageCleanup(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + duration, _ := time.ParseDuration("-1h") + + t.Run("Common", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Upload and delete a generic package and upload a container blob + data, _ := util.CryptoRandomBytes(5) + url := fmt.Sprintf("/api/packages/%s/generic/cleanup-test/1.1.1/file.bin", user.Name) + req := NewRequestWithBody(t, "PUT", url, bytes.NewReader(data)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequest(t, "DELETE", url). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + data, _ = util.CryptoRandomBytes(5) + url = fmt.Sprintf("/v2/%s/cleanup-test/blobs/uploads?digest=sha256:%x", user.Name, sha256.Sum256(data)) + req = NewRequestWithBody(t, "POST", url, bytes.NewReader(data)). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + unittest.AssertExistsAndLoadBean(t, &packages_model.Package{Name: "cleanup-test"}) + + pbs, err := packages_model.FindExpiredUnreferencedBlobs(db.DefaultContext, duration) + require.NoError(t, err) + assert.NotEmpty(t, pbs) + + _, err = packages_model.GetInternalVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeContainer, "cleanup-test", container_model.UploadVersion) + require.NoError(t, err) + + err = packages_cleanup_service.CleanupTask(db.DefaultContext, duration) + require.NoError(t, err) + + pbs, err = packages_model.FindExpiredUnreferencedBlobs(db.DefaultContext, duration) + require.NoError(t, err) + assert.Empty(t, pbs) + + unittest.AssertNotExistsBean(t, &packages_model.Package{Name: "cleanup-test"}) + + _, err = packages_model.GetInternalVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeContainer, "cleanup-test", container_model.UploadVersion) + require.ErrorIs(t, err, packages_model.ErrPackageNotExist) + }) + + t.Run("CleanupRules", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + type version struct { + Version string + ShouldExist bool + Created int64 + } + + cases := []struct { + Name string + Versions []version + Rule *packages_model.PackageCleanupRule + }{ + { + Name: "Disabled", + Versions: []version{ + {Version: "keep", ShouldExist: true}, + }, + Rule: &packages_model.PackageCleanupRule{ + Enabled: false, + }, + }, + { + Name: "KeepCount", + Versions: []version{ + {Version: "keep", ShouldExist: true}, + {Version: "v1.0", ShouldExist: true}, + {Version: "test-3", ShouldExist: false, Created: 1}, + {Version: "test-4", ShouldExist: false, Created: 1}, + }, + Rule: &packages_model.PackageCleanupRule{ + Enabled: true, + KeepCount: 2, + }, + }, + { + Name: "KeepPattern", + Versions: []version{ + {Version: "keep", ShouldExist: true}, + {Version: "v1.0", ShouldExist: false}, + }, + Rule: &packages_model.PackageCleanupRule{ + Enabled: true, + KeepPattern: "k.+p", + }, + }, + { + Name: "RemoveDays", + Versions: []version{ + {Version: "keep", ShouldExist: true}, + {Version: "v1.0", ShouldExist: false, Created: 1}, + }, + Rule: &packages_model.PackageCleanupRule{ + Enabled: true, + RemoveDays: 60, + }, + }, + { + Name: "RemovePattern", + Versions: []version{ + {Version: "test", ShouldExist: true}, + {Version: "test-3", ShouldExist: false}, + {Version: "test-4", ShouldExist: false}, + }, + Rule: &packages_model.PackageCleanupRule{ + Enabled: true, + RemovePattern: `t[e]+st-\d+`, + }, + }, + { + Name: "MatchFullName", + Versions: []version{ + {Version: "keep", ShouldExist: true}, + {Version: "test", ShouldExist: false}, + }, + Rule: &packages_model.PackageCleanupRule{ + Enabled: true, + RemovePattern: `package/test|different/keep`, + MatchFullName: true, + }, + }, + { + Name: "Mixed", + Versions: []version{ + {Version: "keep", ShouldExist: true, Created: time.Now().Add(time.Duration(10000)).Unix()}, + {Version: "dummy", ShouldExist: true, Created: 1}, + {Version: "test-3", ShouldExist: true}, + {Version: "test-4", ShouldExist: false, Created: 1}, + }, + Rule: &packages_model.PackageCleanupRule{ + Enabled: true, + KeepCount: 1, + KeepPattern: `dummy`, + RemoveDays: 7, + RemovePattern: `t[e]+st-\d+`, + }, + }, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + for _, v := range c.Versions { + url := fmt.Sprintf("/api/packages/%s/generic/package/%s/file.bin", user.Name, v.Version) + req := NewRequestWithBody(t, "PUT", url, bytes.NewReader([]byte{1})). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusCreated) + + if v.Created != 0 { + pv, err := packages_model.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeGeneric, "package", v.Version) + require.NoError(t, err) + _, err = db.GetEngine(db.DefaultContext).Exec("UPDATE package_version SET created_unix = ? WHERE id = ?", v.Created, pv.ID) + require.NoError(t, err) + } + } + + c.Rule.OwnerID = user.ID + c.Rule.Type = packages_model.TypeGeneric + + pcr, err := packages_model.InsertCleanupRule(db.DefaultContext, c.Rule) + require.NoError(t, err) + + err = packages_cleanup_service.CleanupTask(db.DefaultContext, duration) + require.NoError(t, err) + + for _, v := range c.Versions { + pv, err := packages_model.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeGeneric, "package", v.Version) + if v.ShouldExist { + require.NoError(t, err) + err = packages_service.DeletePackageVersionAndReferences(db.DefaultContext, pv) + require.NoError(t, err) + } else { + require.ErrorIs(t, err, packages_model.ErrPackageNotExist) + } + } + + require.NoError(t, packages_model.DeleteCleanupRuleByID(db.DefaultContext, pcr.ID)) + }) + } + }) +} diff --git a/tests/integration/api_packages_vagrant_test.go b/tests/integration/api_packages_vagrant_test.go new file mode 100644 index 0000000..b446466 --- /dev/null +++ b/tests/integration/api_packages_vagrant_test.go @@ -0,0 +1,172 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "net/http" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + vagrant_module "code.gitea.io/gitea/modules/packages/vagrant" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPackageVagrant(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + token := "Bearer " + getUserToken(t, user.Name, auth_model.AccessTokenScopeWritePackage) + + packageName := "test_package" + packageVersion := "1.0.1" + packageDescription := "Test Description" + packageProvider := "virtualbox" + + filename := fmt.Sprintf("%s.box", packageProvider) + + infoContent, _ := json.Marshal(map[string]string{ + "description": packageDescription, + }) + + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + archive := tar.NewWriter(zw) + archive.WriteHeader(&tar.Header{ + Name: "info.json", + Mode: 0o600, + Size: int64(len(infoContent)), + }) + archive.Write(infoContent) + archive.Close() + zw.Close() + content := buf.Bytes() + + root := fmt.Sprintf("/api/packages/%s/vagrant", user.Name) + + t.Run("Authenticate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + authenticateURL := fmt.Sprintf("%s/authenticate", root) + + req := NewRequest(t, "GET", authenticateURL) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "GET", authenticateURL). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + }) + + boxURL := fmt.Sprintf("%s/%s", root, packageName) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "HEAD", boxURL) + MakeRequest(t, req, http.StatusNotFound) + + uploadURL := fmt.Sprintf("%s/%s/%s", boxURL, packageVersion, filename) + + req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequest(t, "HEAD", boxURL) + resp := MakeRequest(t, req, http.StatusOK) + assert.True(t, strings.HasPrefix(resp.Header().Get("Content-Type"), "application/json")) + + pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeVagrant) + require.NoError(t, err) + assert.Len(t, pvs, 1) + + pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) + require.NoError(t, err) + assert.NotNil(t, pd.SemVer) + assert.IsType(t, &vagrant_module.Metadata{}, pd.Metadata) + assert.Equal(t, packageName, pd.Package.Name) + assert.Equal(t, packageVersion, pd.Version.Version) + + pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) + require.NoError(t, err) + assert.Len(t, pfs, 1) + assert.Equal(t, filename, pfs[0].Name) + assert.True(t, pfs[0].IsLead) + + pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID) + require.NoError(t, err) + assert.Equal(t, int64(len(content)), pb.Size) + + req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusConflict) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s", boxURL, packageVersion, filename)) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, content, resp.Body.Bytes()) + }) + + t.Run("EnumeratePackageVersions", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", boxURL) + resp := MakeRequest(t, req, http.StatusOK) + + type providerData struct { + Name string `json:"name"` + URL string `json:"url"` + Checksum string `json:"checksum"` + ChecksumType string `json:"checksum_type"` + } + + type versionMetadata struct { + Version string `json:"version"` + Status string `json:"status"` + DescriptionHTML string `json:"description_html,omitempty"` + DescriptionMarkdown string `json:"description_markdown,omitempty"` + Providers []*providerData `json:"providers"` + } + + type packageMetadata struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + ShortDescription string `json:"short_description,omitempty"` + Versions []*versionMetadata `json:"versions"` + } + + var result packageMetadata + DecodeJSON(t, resp, &result) + + assert.Equal(t, packageName, result.Name) + assert.Equal(t, packageDescription, result.Description) + assert.Len(t, result.Versions, 1) + version := result.Versions[0] + assert.Equal(t, packageVersion, version.Version) + assert.Equal(t, "active", version.Status) + assert.Len(t, version.Providers, 1) + provider := version.Providers[0] + assert.Equal(t, packageProvider, provider.Name) + assert.Equal(t, "sha512", provider.ChecksumType) + assert.Equal(t, "259bebd6160acad695016d22a45812e26f187aaf78e71a4c23ee3201528346293f991af3468a8c6c5d2a21d7d9e1bdc1bf79b87110b2fddfcc5a0d45963c7c30", provider.Checksum) + }) +} diff --git a/tests/integration/api_private_serv_test.go b/tests/integration/api_private_serv_test.go new file mode 100644 index 0000000..3339fc4 --- /dev/null +++ b/tests/integration/api_private_serv_test.go @@ -0,0 +1,154 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "net/url" + "testing" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/modules/private" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIPrivateNoServ(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + key, user, err := private.ServNoCommand(ctx, 1) + require.NoError(t, err) + assert.Equal(t, int64(2), user.ID) + assert.Equal(t, "user2", user.Name) + assert.Equal(t, int64(1), key.ID) + assert.Equal(t, "user2@localhost", key.Name) + + deployKey, err := asymkey_model.AddDeployKey(ctx, 1, "test-deploy", "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment", false) + require.NoError(t, err) + + key, user, err = private.ServNoCommand(ctx, deployKey.KeyID) + require.NoError(t, err) + assert.Empty(t, user) + assert.Equal(t, deployKey.KeyID, key.ID) + assert.Equal(t, "test-deploy", key.Name) + }) +} + +func TestAPIPrivateServ(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Can push to a repo we own + results, extra := private.ServCommand(ctx, 1, "user2", "repo1", perm.AccessModeWrite, "git-upload-pack", "") + require.NoError(t, extra.Error) + assert.False(t, results.IsWiki) + assert.Zero(t, results.DeployKeyID) + assert.Equal(t, int64(1), results.KeyID) + assert.Equal(t, "user2@localhost", results.KeyName) + assert.Equal(t, "user2", results.UserName) + assert.Equal(t, int64(2), results.UserID) + assert.Equal(t, "user2", results.OwnerName) + assert.Equal(t, "repo1", results.RepoName) + assert.Equal(t, int64(1), results.RepoID) + + // Cannot push to a private repo we're not associated with + results, extra = private.ServCommand(ctx, 1, "user15", "big_test_private_1", perm.AccessModeWrite, "git-upload-pack", "") + require.Error(t, extra.Error) + assert.Empty(t, results) + + // Cannot pull from a private repo we're not associated with + results, extra = private.ServCommand(ctx, 1, "user15", "big_test_private_1", perm.AccessModeRead, "git-upload-pack", "") + require.Error(t, extra.Error) + assert.Empty(t, results) + + // Can pull from a public repo we're not associated with + results, extra = private.ServCommand(ctx, 1, "user15", "big_test_public_1", perm.AccessModeRead, "git-upload-pack", "") + require.NoError(t, extra.Error) + assert.False(t, results.IsWiki) + assert.Zero(t, results.DeployKeyID) + assert.Equal(t, int64(1), results.KeyID) + assert.Equal(t, "user2@localhost", results.KeyName) + assert.Equal(t, "user2", results.UserName) + assert.Equal(t, int64(2), results.UserID) + assert.Equal(t, "user15", results.OwnerName) + assert.Equal(t, "big_test_public_1", results.RepoName) + assert.Equal(t, int64(17), results.RepoID) + + // Cannot push to a public repo we're not associated with + results, extra = private.ServCommand(ctx, 1, "user15", "big_test_public_1", perm.AccessModeWrite, "git-upload-pack", "") + require.Error(t, extra.Error) + assert.Empty(t, results) + + // Add reading deploy key + deployKey, err := asymkey_model.AddDeployKey(ctx, 19, "test-deploy", "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment", true) + require.NoError(t, err) + + // Can pull from repo we're a deploy key for + results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_1", perm.AccessModeRead, "git-upload-pack", "") + require.NoError(t, extra.Error) + assert.False(t, results.IsWiki) + assert.NotZero(t, results.DeployKeyID) + assert.Equal(t, deployKey.KeyID, results.KeyID) + assert.Equal(t, "test-deploy", results.KeyName) + assert.Equal(t, "user15", results.UserName) + assert.Equal(t, int64(15), results.UserID) + assert.Equal(t, "user15", results.OwnerName) + assert.Equal(t, "big_test_private_1", results.RepoName) + assert.Equal(t, int64(19), results.RepoID) + + // Cannot push to a private repo with reading key + results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_1", perm.AccessModeWrite, "git-upload-pack", "") + require.Error(t, extra.Error) + assert.Empty(t, results) + + // Cannot pull from a private repo we're not associated with + results, extra = private.ServCommand(ctx, deployKey.ID, "user15", "big_test_private_2", perm.AccessModeRead, "git-upload-pack", "") + require.Error(t, extra.Error) + assert.Empty(t, results) + + // Cannot pull from a public repo we're not associated with + results, extra = private.ServCommand(ctx, deployKey.ID, "user15", "big_test_public_1", perm.AccessModeRead, "git-upload-pack", "") + require.Error(t, extra.Error) + assert.Empty(t, results) + + // Add writing deploy key + deployKey, err = asymkey_model.AddDeployKey(ctx, 20, "test-deploy", "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment", false) + require.NoError(t, err) + + // Cannot push to a private repo with reading key + results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_1", perm.AccessModeWrite, "git-upload-pack", "") + require.Error(t, extra.Error) + assert.Empty(t, results) + + // Can pull from repo we're a writing deploy key for + results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_2", perm.AccessModeRead, "git-upload-pack", "") + require.NoError(t, extra.Error) + assert.False(t, results.IsWiki) + assert.NotZero(t, results.DeployKeyID) + assert.Equal(t, deployKey.KeyID, results.KeyID) + assert.Equal(t, "test-deploy", results.KeyName) + assert.Equal(t, "user15", results.UserName) + assert.Equal(t, int64(15), results.UserID) + assert.Equal(t, "user15", results.OwnerName) + assert.Equal(t, "big_test_private_2", results.RepoName) + assert.Equal(t, int64(20), results.RepoID) + + // Can push to repo we're a writing deploy key for + results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_2", perm.AccessModeWrite, "git-upload-pack", "") + require.NoError(t, extra.Error) + assert.False(t, results.IsWiki) + assert.NotZero(t, results.DeployKeyID) + assert.Equal(t, deployKey.KeyID, results.KeyID) + assert.Equal(t, "test-deploy", results.KeyName) + assert.Equal(t, "user15", results.UserName) + assert.Equal(t, int64(15), results.UserID) + assert.Equal(t, "user15", results.OwnerName) + assert.Equal(t, "big_test_private_2", results.RepoName) + assert.Equal(t, int64(20), results.RepoID) + }) +} diff --git a/tests/integration/api_pull_commits_test.go b/tests/integration/api_pull_commits_test.go new file mode 100644 index 0000000..d62b9d9 --- /dev/null +++ b/tests/integration/api_pull_commits_test.go @@ -0,0 +1,46 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIPullCommits(t *testing.T) { + defer tests.PrepareTestEnv(t)() + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}) + require.NoError(t, pr.LoadIssue(db.DefaultContext)) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pr.HeadRepoID}) + + req := NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/%s/pulls/%d/commits", repo.OwnerName, repo.Name, pr.Index) + resp := MakeRequest(t, req, http.StatusOK) + + var commits []*api.Commit + DecodeJSON(t, resp, &commits) + + if !assert.Len(t, commits, 2) { + return + } + + assert.Equal(t, "5f22f7d0d95d614d25a5b68592adb345a4b5c7fd", commits[0].SHA) + assert.Equal(t, "4a357436d925b5c974181ff12a994538ddc5a269", commits[1].SHA) + + assert.NotEmpty(t, commits[0].Files) + assert.NotEmpty(t, commits[1].Files) + assert.NotNil(t, commits[0].RepoCommit.Verification) + assert.NotNil(t, commits[1].RepoCommit.Verification) +} + +// TODO add tests for already merged PR and closed PR diff --git a/tests/integration/api_pull_review_test.go b/tests/integration/api_pull_review_test.go new file mode 100644 index 0000000..b66e65e --- /dev/null +++ b/tests/integration/api_pull_review_test.go @@ -0,0 +1,610 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + api "code.gitea.io/gitea/modules/structs" + issue_service "code.gitea.io/gitea/services/issue" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "xorm.io/builder" +) + +func TestAPIPullReviewCreateDeleteComment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + pullIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) + require.NoError(t, pullIssue.LoadAttributes(db.DefaultContext)) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue.RepoID}) + + username := "user2" + session := loginUser(t, username) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // as of e522e774cae2240279fc48c349fc513c9d3353ee + // There should be no reason for CreateComment to behave differently + // depending on the event associated with the review. But the logic of the implementation + // at this point in time is very involved and deserves these seemingly redundant + // test. + for _, event := range []api.ReviewStateType{ + api.ReviewStatePending, + api.ReviewStateRequestChanges, + api.ReviewStateApproved, + api.ReviewStateComment, + } { + t.Run("Event_"+string(event), func(t *testing.T) { + path := "README.md" + var review api.PullReview + var reviewLine int64 = 1 + + // cleanup + { + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll) + + req := NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/pulls/%d/reviews", repo.FullName(), pullIssue.Index).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var reviews []*api.PullReview + DecodeJSON(t, resp, &reviews) + for _, review := range reviews { + if review.State == api.ReviewStateRequestReview { + continue + } + req := NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/pulls/%d/reviews/%d", repo.FullName(), pullIssue.Index, review.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + } + } + + requireReviewCount := func(count int) { + req := NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/pulls/%d/reviews", repo.FullName(), pullIssue.Index).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var reviews []*api.PullReview + DecodeJSON(t, resp, &reviews) + require.Len(t, reviews, count) + } + + { + req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/pulls/%d/reviews", repo.FullName(), pullIssue.Index), &api.CreatePullReviewOptions{ + Body: "body1", + Event: event, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &review) + require.EqualValues(t, string(event), review.State) + require.EqualValues(t, 0, review.CodeCommentsCount) + } + + { + req := NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/pulls/%d/reviews/%d", repo.FullName(), pullIssue.Index, review.ID). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var getReview api.PullReview + DecodeJSON(t, resp, &getReview) + require.EqualValues(t, getReview, review) + } + requireReviewCount(2) + + newCommentBody := "first new line" + var reviewComment api.PullReviewComment + + { + req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/pulls/%d/reviews/%d/comments", repo.FullName(), pullIssue.Index, review.ID), &api.CreatePullReviewCommentOptions{ + Path: path, + Body: newCommentBody, + OldLineNum: reviewLine, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &reviewComment) + assert.EqualValues(t, review.ID, reviewComment.ReviewID) + assert.EqualValues(t, newCommentBody, reviewComment.Body) + assert.EqualValues(t, reviewLine, reviewComment.OldLineNum) + assert.EqualValues(t, 0, reviewComment.LineNum) + assert.EqualValues(t, path, reviewComment.Path) + } + + { + req := NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/pulls/%d/reviews/%d/comments/%d", repo.FullName(), pullIssue.Index, review.ID, reviewComment.ID). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var comment api.PullReviewComment + DecodeJSON(t, resp, &comment) + assert.EqualValues(t, reviewComment, comment) + } + + { + req := NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/pulls/%d/reviews/%d/comments/%d", repo.FullName(), pullIssue.Index, review.ID, reviewComment.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + } + + { + req := NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/pulls/%d/reviews/%d/comments/%d", repo.FullName(), pullIssue.Index, review.ID, reviewComment.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + } + + { + req := NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/pulls/%d/reviews/%d", repo.FullName(), pullIssue.Index, review.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + } + requireReviewCount(1) + }) + } +} + +func TestAPIPullReview(t *testing.T) { + defer tests.PrepareTestEnv(t)() + pullIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) + require.NoError(t, pullIssue.LoadAttributes(db.DefaultContext)) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue.RepoID}) + + // test ListPullReviews + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var reviews []*api.PullReview + DecodeJSON(t, resp, &reviews) + if !assert.Len(t, reviews, 8) { + return + } + for _, r := range reviews { + assert.EqualValues(t, pullIssue.HTMLURL(), r.HTMLPullURL) + } + assert.EqualValues(t, 8, reviews[3].ID) + assert.EqualValues(t, "APPROVED", reviews[3].State) + assert.EqualValues(t, 0, reviews[3].CodeCommentsCount) + assert.True(t, reviews[3].Stale) + assert.False(t, reviews[3].Official) + + assert.EqualValues(t, 10, reviews[5].ID) + assert.EqualValues(t, "REQUEST_CHANGES", reviews[5].State) + assert.EqualValues(t, 1, reviews[5].CodeCommentsCount) + assert.EqualValues(t, -1, reviews[5].Reviewer.ID) // ghost user + assert.False(t, reviews[5].Stale) + assert.True(t, reviews[5].Official) + + // test GetPullReview + req = NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/%s/pulls/%d/reviews/%d", repo.OwnerName, repo.Name, pullIssue.Index, reviews[3].ID). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var review api.PullReview + DecodeJSON(t, resp, &review) + assert.EqualValues(t, *reviews[3], review) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/pulls/%d/reviews/%d", repo.OwnerName, repo.Name, pullIssue.Index, reviews[5].ID). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &review) + assert.EqualValues(t, *reviews[5], review) + + // test GetPullReviewComments + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 7}) + req = NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/%s/pulls/%d/reviews/%d/comments", repo.OwnerName, repo.Name, pullIssue.Index, 10). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var reviewComments []*api.PullReviewComment + DecodeJSON(t, resp, &reviewComments) + assert.Len(t, reviewComments, 1) + assert.EqualValues(t, "Ghost", reviewComments[0].Poster.UserName) + assert.EqualValues(t, "a review from a deleted user", reviewComments[0].Body) + assert.EqualValues(t, comment.ID, reviewComments[0].ID) + assert.EqualValues(t, comment.UpdatedUnix, reviewComments[0].Updated.Unix()) + assert.EqualValues(t, comment.HTMLURL(db.DefaultContext), reviewComments[0].HTMLURL) + + // test CreatePullReview + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index), &api.CreatePullReviewOptions{ + Body: "body1", + // Event: "" # will result in PENDING + Comments: []api.CreatePullReviewComment{ + { + Path: "README.md", + Body: "first new line", + OldLineNum: 0, + NewLineNum: 1, + }, { + Path: "README.md", + Body: "first old line", + OldLineNum: 1, + NewLineNum: 0, + }, { + Path: "iso-8859-1.txt", + Body: "this line contains a non-utf-8 character", + OldLineNum: 0, + NewLineNum: 1, + }, + }, + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &review) + assert.EqualValues(t, 6, review.ID) + assert.EqualValues(t, "PENDING", review.State) + assert.EqualValues(t, 3, review.CodeCommentsCount) + + // test SubmitPullReview + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/%d", repo.OwnerName, repo.Name, pullIssue.Index, review.ID), &api.SubmitPullReviewOptions{ + Event: "APPROVED", + Body: "just two nits", + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &review) + assert.EqualValues(t, 6, review.ID) + assert.EqualValues(t, "APPROVED", review.State) + assert.EqualValues(t, 3, review.CodeCommentsCount) + + // test dismiss review + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/%d/dismissals", repo.OwnerName, repo.Name, pullIssue.Index, review.ID), &api.DismissPullReviewOptions{ + Message: "test", + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &review) + assert.EqualValues(t, 6, review.ID) + assert.True(t, review.Dismissed) + + // test dismiss review + req = NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/%d/undismissals", repo.OwnerName, repo.Name, pullIssue.Index, review.ID)). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &review) + assert.EqualValues(t, 6, review.ID) + assert.False(t, review.Dismissed) + + // test DeletePullReview + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index), &api.CreatePullReviewOptions{ + Body: "just a comment", + Event: "COMMENT", + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &review) + assert.EqualValues(t, "COMMENT", review.State) + assert.EqualValues(t, 0, review.CodeCommentsCount) + req = NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/%s/pulls/%d/reviews/%d", repo.OwnerName, repo.Name, pullIssue.Index, review.ID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // test CreatePullReview Comment without body but with comments + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index), &api.CreatePullReviewOptions{ + // Body: "", + Event: "COMMENT", + Comments: []api.CreatePullReviewComment{ + { + Path: "README.md", + Body: "first new line", + OldLineNum: 0, + NewLineNum: 1, + }, { + Path: "README.md", + Body: "first old line", + OldLineNum: 1, + NewLineNum: 0, + }, + }, + }).AddTokenAuth(token) + var commentReview api.PullReview + + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &commentReview) + assert.EqualValues(t, "COMMENT", commentReview.State) + assert.EqualValues(t, 2, commentReview.CodeCommentsCount) + assert.Empty(t, commentReview.Body) + assert.False(t, commentReview.Dismissed) + + // test CreatePullReview Comment with body but without comments + commentBody := "This is a body of the comment." + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index), &api.CreatePullReviewOptions{ + Body: commentBody, + Event: "COMMENT", + Comments: []api.CreatePullReviewComment{}, + }).AddTokenAuth(token) + + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &commentReview) + assert.EqualValues(t, "COMMENT", commentReview.State) + assert.EqualValues(t, 0, commentReview.CodeCommentsCount) + assert.EqualValues(t, commentBody, commentReview.Body) + assert.False(t, commentReview.Dismissed) + + // test CreatePullReview Comment without body and no comments + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index), &api.CreatePullReviewOptions{ + Body: "", + Event: "COMMENT", + Comments: []api.CreatePullReviewComment{}, + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusUnprocessableEntity) + errMap := make(map[string]any) + json.Unmarshal(resp.Body.Bytes(), &errMap) + assert.EqualValues(t, "review event COMMENT requires a body or a comment", errMap["message"].(string)) + + // test get review requests + // to make it simple, use same api with get review + pullIssue12 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 12}) + require.NoError(t, pullIssue12.LoadAttributes(db.DefaultContext)) + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue12.RepoID}) + + req = NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/%s/pulls/%d/reviews", repo3.OwnerName, repo3.Name, pullIssue12.Index). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &reviews) + assert.EqualValues(t, 11, reviews[0].ID) + assert.EqualValues(t, "REQUEST_REVIEW", reviews[0].State) + assert.EqualValues(t, 0, reviews[0].CodeCommentsCount) + assert.False(t, reviews[0].Stale) + assert.True(t, reviews[0].Official) + assert.EqualValues(t, "test_team", reviews[0].ReviewerTeam.Name) + + assert.EqualValues(t, 12, reviews[1].ID) + assert.EqualValues(t, "REQUEST_REVIEW", reviews[1].State) + assert.EqualValues(t, 0, reviews[0].CodeCommentsCount) + assert.False(t, reviews[1].Stale) + assert.True(t, reviews[1].Official) + assert.EqualValues(t, 1, reviews[1].Reviewer.ID) +} + +func TestAPIPullReviewRequest(t *testing.T) { + defer tests.PrepareTestEnv(t)() + pullIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) + require.NoError(t, pullIssue.LoadAttributes(db.DefaultContext)) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue.RepoID}) + + // Test add Review Request + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{ + Reviewers: []string{"user4@example.com", "user8"}, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // poster of pr can't be reviewer + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{ + Reviewers: []string{"user1"}, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + // test user not exist + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{ + Reviewers: []string{"testOther"}, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + // Test Remove Review Request + session2 := loginUser(t, "user4") + token2 := getTokenForLoggedInUser(t, session2, auth_model.AccessTokenScopeWriteRepository) + + req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{ + Reviewers: []string{"user4"}, + }).AddTokenAuth(token2) + MakeRequest(t, req, http.StatusNoContent) + + // doer is not admin + req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{ + Reviewers: []string{"user8"}, + }).AddTokenAuth(token2) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{ + Reviewers: []string{"user8"}, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // a collaborator can add/remove a review request + pullIssue21 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 21}) + require.NoError(t, pullIssue21.LoadAttributes(db.DefaultContext)) + pull21Repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue21.RepoID}) // repo60 + user38Session := loginUser(t, "user38") + user38Token := getTokenForLoggedInUser(t, user38Session, auth_model.AccessTokenScopeWriteRepository) + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", pull21Repo.OwnerName, pull21Repo.Name, pullIssue21.Index), &api.PullReviewRequestOptions{ + Reviewers: []string{"user4@example.com"}, + }).AddTokenAuth(user38Token) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", pull21Repo.OwnerName, pull21Repo.Name, pullIssue21.Index), &api.PullReviewRequestOptions{ + Reviewers: []string{"user4@example.com"}, + }).AddTokenAuth(user38Token) + MakeRequest(t, req, http.StatusNoContent) + + // the poster of the PR can add/remove a review request + user39Session := loginUser(t, "user39") + user39Token := getTokenForLoggedInUser(t, user39Session, auth_model.AccessTokenScopeWriteRepository) + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", pull21Repo.OwnerName, pull21Repo.Name, pullIssue21.Index), &api.PullReviewRequestOptions{ + Reviewers: []string{"user8"}, + }).AddTokenAuth(user39Token) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", pull21Repo.OwnerName, pull21Repo.Name, pullIssue21.Index), &api.PullReviewRequestOptions{ + Reviewers: []string{"user8"}, + }).AddTokenAuth(user39Token) + MakeRequest(t, req, http.StatusNoContent) + + // user with read permission on pull requests unit can add/remove a review request + pullIssue22 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 22}) + require.NoError(t, pullIssue22.LoadAttributes(db.DefaultContext)) + pull22Repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue22.RepoID}) // repo61 + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", pull22Repo.OwnerName, pull22Repo.Name, pullIssue22.Index), &api.PullReviewRequestOptions{ + Reviewers: []string{"user38"}, + }).AddTokenAuth(user39Token) // user39 is from a team with read permission on pull requests unit + MakeRequest(t, req, http.StatusCreated) + + req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", pull22Repo.OwnerName, pull22Repo.Name, pullIssue22.Index), &api.PullReviewRequestOptions{ + Reviewers: []string{"user38"}, + }).AddTokenAuth(user39Token) // user39 is from a team with read permission on pull requests unit + MakeRequest(t, req, http.StatusNoContent) + + // Test team review request + pullIssue12 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 12}) + require.NoError(t, pullIssue12.LoadAttributes(db.DefaultContext)) + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue12.RepoID}) + + // Test add Team Review Request + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo3.OwnerName, repo3.Name, pullIssue12.Index), &api.PullReviewRequestOptions{ + TeamReviewers: []string{"team1", "owners"}, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + // Test add Team Review Request to not allowned + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo3.OwnerName, repo3.Name, pullIssue12.Index), &api.PullReviewRequestOptions{ + TeamReviewers: []string{"test_team"}, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + // Test add Team Review Request to not exist + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo3.OwnerName, repo3.Name, pullIssue12.Index), &api.PullReviewRequestOptions{ + TeamReviewers: []string{"not_exist_team"}, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + // Test Remove team Review Request + req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo3.OwnerName, repo3.Name, pullIssue12.Index), &api.PullReviewRequestOptions{ + TeamReviewers: []string{"team1"}, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // empty request test + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo3.OwnerName, repo3.Name, pullIssue12.Index), &api.PullReviewRequestOptions{}). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo3.OwnerName, repo3.Name, pullIssue12.Index), &api.PullReviewRequestOptions{}). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) +} + +func TestAPIPullReviewStayDismissed(t *testing.T) { + // This test against issue https://github.com/go-gitea/gitea/issues/28542 + // where old reviews surface after a review request got dismissed. + defer tests.PrepareTestEnv(t)() + pullIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) + require.NoError(t, pullIssue.LoadAttributes(db.DefaultContext)) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue.RepoID}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session2 := loginUser(t, user2.LoginName) + token2 := getTokenForLoggedInUser(t, session2, auth_model.AccessTokenScopeWriteRepository) + user8 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8}) + session8 := loginUser(t, user8.LoginName) + token8 := getTokenForLoggedInUser(t, session8, auth_model.AccessTokenScopeWriteRepository) + + // user2 request user8 + req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{ + Reviewers: []string{user8.LoginName}, + }).AddTokenAuth(token2) + MakeRequest(t, req, http.StatusCreated) + + reviewsCountCheck(t, + "check we have only one review request", + pullIssue.ID, user8.ID, 0, 1, 1, false) + + // user2 request user8 again, it is expected to be ignored + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{ + Reviewers: []string{user8.LoginName}, + }).AddTokenAuth(token2) + MakeRequest(t, req, http.StatusCreated) + + reviewsCountCheck(t, + "check we have only one review request, even after re-request it again", + pullIssue.ID, user8.ID, 0, 1, 1, false) + + // user8 reviews it as accept + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index), &api.CreatePullReviewOptions{ + Event: "APPROVED", + Body: "lgtm", + }).AddTokenAuth(token8) + MakeRequest(t, req, http.StatusOK) + + reviewsCountCheck(t, + "check we have one valid approval", + pullIssue.ID, user8.ID, 0, 0, 1, true) + + // emulate of auto-dismiss lgtm on a protected branch that where a pull just got an update + _, err := db.GetEngine(db.DefaultContext).Where("issue_id = ? AND reviewer_id = ?", pullIssue.ID, user8.ID). + Cols("dismissed").Update(&issues_model.Review{Dismissed: true}) + require.NoError(t, err) + + // user2 request user8 again + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{ + Reviewers: []string{user8.LoginName}, + }).AddTokenAuth(token2) + MakeRequest(t, req, http.StatusCreated) + + reviewsCountCheck(t, + "check we have no valid approval and one review request", + pullIssue.ID, user8.ID, 1, 1, 2, false) + + // user8 dismiss review + _, err = issue_service.ReviewRequest(db.DefaultContext, pullIssue, user8, user8, false) + require.NoError(t, err) + + reviewsCountCheck(t, + "check new review request is now dismissed", + pullIssue.ID, user8.ID, 1, 0, 1, false) + + // add a new valid approval + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index), &api.CreatePullReviewOptions{ + Event: "APPROVED", + Body: "lgtm", + }).AddTokenAuth(token8) + MakeRequest(t, req, http.StatusOK) + + reviewsCountCheck(t, + "check that old reviews requests are deleted", + pullIssue.ID, user8.ID, 1, 0, 2, true) + + // now add a change request witch should dismiss the approval + req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index), &api.CreatePullReviewOptions{ + Event: "REQUEST_CHANGES", + Body: "please change XYZ", + }).AddTokenAuth(token8) + MakeRequest(t, req, http.StatusOK) + + reviewsCountCheck(t, + "check that old reviews are dismissed", + pullIssue.ID, user8.ID, 2, 0, 3, false) +} + +func reviewsCountCheck(t *testing.T, name string, issueID, reviewerID int64, expectedDismissed, expectedRequested, expectedTotal int, expectApproval bool) { + t.Run(name, func(t *testing.T) { + unittest.AssertCountByCond(t, "review", builder.Eq{ + "issue_id": issueID, + "reviewer_id": reviewerID, + "dismissed": true, + }, expectedDismissed) + + unittest.AssertCountByCond(t, "review", builder.Eq{ + "issue_id": issueID, + "reviewer_id": reviewerID, + }, expectedTotal) + + unittest.AssertCountByCond(t, "review", builder.Eq{ + "issue_id": issueID, + "reviewer_id": reviewerID, + "type": issues_model.ReviewTypeRequest, + }, expectedRequested) + + approvalCount := 0 + if expectApproval { + approvalCount = 1 + } + unittest.AssertCountByCond(t, "review", builder.Eq{ + "issue_id": issueID, + "reviewer_id": reviewerID, + "type": issues_model.ReviewTypeApprove, + "dismissed": false, + }, approvalCount) + }) +} diff --git a/tests/integration/api_pull_test.go b/tests/integration/api_pull_test.go new file mode 100644 index 0000000..7b95d44 --- /dev/null +++ b/tests/integration/api_pull_test.go @@ -0,0 +1,349 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/forms" + issue_service "code.gitea.io/gitea/services/issue" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIViewPulls(t *testing.T) { + defer tests.PrepareTestEnv(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + ctx := NewAPITestContext(t, "user2", repo.Name, auth_model.AccessTokenScopeReadRepository) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/pulls?state=all", owner.Name, repo.Name). + AddTokenAuth(ctx.Token) + resp := ctx.Session.MakeRequest(t, req, http.StatusOK) + + var pulls []*api.PullRequest + DecodeJSON(t, resp, &pulls) + expectedLen := unittest.GetCount(t, &issues_model.Issue{RepoID: repo.ID}, unittest.Cond("is_pull = ?", true)) + assert.Len(t, pulls, expectedLen) + + pull := pulls[0] + if assert.EqualValues(t, 5, pull.ID) { + resp = ctx.Session.MakeRequest(t, NewRequest(t, "GET", pull.DiffURL), http.StatusOK) + _, err := io.ReadAll(resp.Body) + require.NoError(t, err) + // TODO: use diff to generate stats to test against + + t.Run(fmt.Sprintf("APIGetPullFiles_%d", pull.ID), + doAPIGetPullFiles(ctx, pull, func(t *testing.T, files []*api.ChangedFile) { + if assert.Len(t, files, 1) { + assert.Equal(t, "File-WoW", files[0].Filename) + assert.Empty(t, files[0].PreviousFilename) + assert.EqualValues(t, 1, files[0].Additions) + assert.EqualValues(t, 1, files[0].Changes) + assert.EqualValues(t, 0, files[0].Deletions) + assert.Equal(t, "added", files[0].Status) + } + })) + } +} + +func TestAPIViewPullsByBaseHead(t *testing.T) { + defer tests.PrepareTestEnv(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + ctx := NewAPITestContext(t, "user2", repo.Name, auth_model.AccessTokenScopeReadRepository) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/pulls/master/branch2", owner.Name, repo.Name). + AddTokenAuth(ctx.Token) + resp := ctx.Session.MakeRequest(t, req, http.StatusOK) + + pull := &api.PullRequest{} + DecodeJSON(t, resp, pull) + assert.EqualValues(t, 3, pull.Index) + assert.EqualValues(t, 2, pull.ID) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/pulls/master/branch-not-exist", owner.Name, repo.Name). + AddTokenAuth(ctx.Token) + ctx.Session.MakeRequest(t, req, http.StatusNotFound) +} + +// TestAPIMergePullWIP ensures that we can't merge a WIP pull request +func TestAPIMergePullWIP(t *testing.T) { + defer tests.PrepareTestEnv(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{Status: issues_model.PullRequestStatusMergeable}, unittest.Cond("has_merged = ?", false)) + pr.LoadIssue(db.DefaultContext) + issue_service.ChangeTitle(db.DefaultContext, pr.Issue, owner, setting.Repository.PullRequest.WorkInProgressPrefixes[0]+" "+pr.Issue.Title) + + // force reload + pr.LoadAttributes(db.DefaultContext) + + assert.Contains(t, pr.Issue.Title, setting.Repository.PullRequest.WorkInProgressPrefixes[0]) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner.Name, repo.Name, pr.Index), &forms.MergePullRequestForm{ + MergeMessageField: pr.Issue.Title, + Do: string(repo_model.MergeStyleMerge), + }).AddTokenAuth(token) + + MakeRequest(t, req, http.StatusMethodNotAllowed) +} + +func TestAPICreatePullSuccess(t *testing.T) { + defer tests.PrepareTestEnv(t)() + repo10 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10}) + // repo10 have code, pulls units. + repo11 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11}) + // repo11 only have code unit but should still create pulls + owner10 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo10.OwnerID}) + owner11 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo11.OwnerID}) + + session := loginUser(t, owner11.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner10.Name, repo10.Name), &api.CreatePullRequestOption{ + Head: fmt.Sprintf("%s:master", owner11.Name), + Base: "master", + Title: "create a failure pr", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + MakeRequest(t, req, http.StatusUnprocessableEntity) // second request should fail +} + +func TestAPICreatePullSameRepoSuccess(t *testing.T) { + defer tests.PrepareTestEnv(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner.Name, repo.Name), &api.CreatePullRequestOption{ + Head: fmt.Sprintf("%s:pr-to-update", owner.Name), + Base: "master", + Title: "successfully create a PR between branches of the same repository", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + MakeRequest(t, req, http.StatusUnprocessableEntity) // second request should fail +} + +func TestAPICreatePullWithFieldsSuccess(t *testing.T) { + defer tests.PrepareTestEnv(t)() + // repo10 have code, pulls units. + repo10 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10}) + owner10 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo10.OwnerID}) + // repo11 only have code unit but should still create pulls + repo11 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11}) + owner11 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo11.OwnerID}) + + session := loginUser(t, owner11.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + opts := &api.CreatePullRequestOption{ + Head: fmt.Sprintf("%s:master", owner11.Name), + Base: "master", + Title: "create a failure pr", + Body: "foobaaar", + Milestone: 5, + Assignees: []string{owner10.Name}, + Labels: []int64{5}, + } + + req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner10.Name, repo10.Name), opts). + AddTokenAuth(token) + + res := MakeRequest(t, req, http.StatusCreated) + pull := new(api.PullRequest) + DecodeJSON(t, res, pull) + + assert.NotNil(t, pull.Milestone) + assert.EqualValues(t, opts.Milestone, pull.Milestone.ID) + if assert.Len(t, pull.Assignees, 1) { + assert.EqualValues(t, opts.Assignees[0], owner10.Name) + } + assert.NotNil(t, pull.Labels) + assert.EqualValues(t, opts.Labels[0], pull.Labels[0].ID) +} + +func TestAPICreatePullWithFieldsFailure(t *testing.T) { + defer tests.PrepareTestEnv(t)() + // repo10 have code, pulls units. + repo10 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10}) + owner10 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo10.OwnerID}) + // repo11 only have code unit but should still create pulls + repo11 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11}) + owner11 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo11.OwnerID}) + + session := loginUser(t, owner11.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + opts := &api.CreatePullRequestOption{ + Head: fmt.Sprintf("%s:master", owner11.Name), + Base: "master", + } + + req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner10.Name, repo10.Name), opts). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + opts.Title = "is required" + + opts.Milestone = 666 + MakeRequest(t, req, http.StatusUnprocessableEntity) + opts.Milestone = 5 + + opts.Assignees = []string{"qweruqweroiuyqweoiruywqer"} + MakeRequest(t, req, http.StatusUnprocessableEntity) + opts.Assignees = []string{owner10.LoginName} + + opts.Labels = []int64{55555} + MakeRequest(t, req, http.StatusUnprocessableEntity) + opts.Labels = []int64{5} +} + +func TestAPIEditPull(t *testing.T) { + defer tests.PrepareTestEnv(t)() + repo10 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10}) + owner10 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo10.OwnerID}) + + session := loginUser(t, owner10.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + title := "create a success pr" + req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner10.Name, repo10.Name), &api.CreatePullRequestOption{ + Head: "develop", + Base: "master", + Title: title, + }).AddTokenAuth(token) + apiPull := new(api.PullRequest) + resp := MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, apiPull) + assert.EqualValues(t, "master", apiPull.Base.Name) + + newTitle := "edit a this pr" + newBody := "edited body" + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner10.Name, repo10.Name, apiPull.Index) + req = NewRequestWithJSON(t, http.MethodPatch, urlStr, &api.EditPullRequestOption{ + Base: "feature/1", + Title: newTitle, + Body: &newBody, + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, apiPull) + assert.EqualValues(t, "feature/1", apiPull.Base.Name) + // check comment history + pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: apiPull.ID}) + err := pull.LoadIssue(db.DefaultContext) + require.NoError(t, err) + unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: pull.Issue.ID, OldTitle: title, NewTitle: newTitle}) + unittest.AssertExistsAndLoadBean(t, &issues_model.ContentHistory{IssueID: pull.Issue.ID, ContentText: newBody, IsFirstCreated: false}) + + // verify the idempotency of a state change + pullState := string(apiPull.State) + req = NewRequestWithJSON(t, http.MethodPatch, urlStr, &api.EditPullRequestOption{ + State: &pullState, + }).AddTokenAuth(token) + apiPullIdempotent := new(api.PullRequest) + resp = MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, apiPullIdempotent) + assert.EqualValues(t, apiPull.State, apiPullIdempotent.State) + + req = NewRequestWithJSON(t, http.MethodPatch, urlStr, &api.EditPullRequestOption{ + Base: "not-exist", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestAPIForkDifferentName(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // Step 1: get a repo and a user that can fork this repo + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + // Step 2: fork this repo with another name + forkName := "myfork" + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", owner.Name, repo.Name), + &api.CreateForkOption{Name: &forkName}).AddTokenAuth(token) + MakeRequest(t, req, http.StatusAccepted) + + // Step 3: make a PR onto the original repo, it should succeed + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls?state=all", owner.Name, repo.Name), + &api.CreatePullRequestOption{Head: user.Name + ":master", Base: "master", Title: "hi"}).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) +} + +func doAPIGetPullFiles(ctx APITestContext, pr *api.PullRequest, callback func(*testing.T, []*api.ChangedFile)) func(*testing.T) { + return func(t *testing.T) { + req := NewRequest(t, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/files", ctx.Username, ctx.Reponame, pr.Index)). + AddTokenAuth(ctx.Token) + if ctx.ExpectedCode == 0 { + ctx.ExpectedCode = http.StatusOK + } + resp := ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + + files := make([]*api.ChangedFile, 0, 1) + DecodeJSON(t, resp, &files) + + if callback != nil { + callback(t, files) + } + } +} + +func TestAPIPullDeleteBranchPerms(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + user2Session := loginUser(t, "user2") + user4Session := loginUser(t, "user4") + testRepoFork(t, user4Session, "user2", "repo1", "user4", "repo1") + testEditFileToNewBranch(t, user2Session, "user2", "repo1", "master", "base-pr", "README.md", "Hello, World\n(Edited - base PR)\n") + + req := NewRequestWithValues(t, "POST", "/user4/repo1/compare/master...user2/repo1:base-pr", map[string]string{ + "_csrf": GetCSRF(t, user4Session, "/user4/repo1/compare/master...user2/repo1:base-pr"), + "title": "Testing PR", + }) + resp := user4Session.MakeRequest(t, req, http.StatusOK) + elem := strings.Split(test.RedirectURL(resp), "/") + + token := getTokenForLoggedInUser(t, user4Session, auth_model.AccessTokenScopeWriteRepository) + req = NewRequestWithValues(t, "POST", "/api/v1/repos/user4/repo1/pulls/"+elem[4]+"/merge", map[string]string{ + "do": "merge", + "delete_branch_after_merge": "on", + }).AddTokenAuth(token) + resp = user4Session.MakeRequest(t, req, http.StatusForbidden) + + type userResponse struct { + Message string `json:"message"` + } + var bodyResp userResponse + DecodeJSON(t, resp, &bodyResp) + + assert.EqualValues(t, "insufficient permission to delete head branch", bodyResp.Message) + + // Check that the branch still exist. + req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/branches/base-pr").AddTokenAuth(token) + user4Session.MakeRequest(t, req, http.StatusOK) + }) +} diff --git a/tests/integration/api_push_mirror_test.go b/tests/integration/api_push_mirror_test.go new file mode 100644 index 0000000..f2135ce --- /dev/null +++ b/tests/integration/api_push_mirror_test.go @@ -0,0 +1,291 @@ +// Copyright The Forgejo Authors +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + "time" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/migrations" + mirror_service "code.gitea.io/gitea/services/mirror" + repo_service "code.gitea.io/gitea/services/repository" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIPushMirror(t *testing.T) { + onGiteaRun(t, testAPIPushMirror) +} + +func testAPIPushMirror(t *testing.T, u *url.URL) { + defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)() + defer test.MockVariableValue(&setting.Mirror.Enabled, true)() + defer test.MockProtect(&mirror_service.AddPushMirrorRemote)() + defer test.MockProtect(&repo_model.DeletePushMirrors)() + + require.NoError(t, migrations.Init()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + srcRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: srcRepo.OwnerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors", owner.Name, srcRepo.Name) + + mirrorRepo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user, user, repo_service.CreateRepoOptions{ + Name: "test-push-mirror", + }) + require.NoError(t, err) + remoteAddress := fmt.Sprintf("%s%s/%s", u.String(), url.PathEscape(user.Name), url.PathEscape(mirrorRepo.Name)) + + deletePushMirrors := repo_model.DeletePushMirrors + deletePushMirrorsError := errors.New("deletePushMirrorsError") + deletePushMirrorsFail := func(ctx context.Context, opts repo_model.PushMirrorOptions) error { + return deletePushMirrorsError + } + + addPushMirrorRemote := mirror_service.AddPushMirrorRemote + addPushMirrorRemoteError := errors.New("addPushMirrorRemoteError") + addPushMirrorRemoteFail := func(ctx context.Context, m *repo_model.PushMirror, addr string) error { + return addPushMirrorRemoteError + } + + for _, testCase := range []struct { + name string + message string + status int + mirrorCount int + setup func() + }{ + { + name: "success", + status: http.StatusOK, + mirrorCount: 1, + setup: func() { + mirror_service.AddPushMirrorRemote = addPushMirrorRemote + repo_model.DeletePushMirrors = deletePushMirrors + }, + }, + { + name: "fail to add and delete", + message: deletePushMirrorsError.Error(), + status: http.StatusInternalServerError, + mirrorCount: 1, + setup: func() { + mirror_service.AddPushMirrorRemote = addPushMirrorRemoteFail + repo_model.DeletePushMirrors = deletePushMirrorsFail + }, + }, + { + name: "fail to add", + message: addPushMirrorRemoteError.Error(), + status: http.StatusInternalServerError, + mirrorCount: 0, + setup: func() { + mirror_service.AddPushMirrorRemote = addPushMirrorRemoteFail + repo_model.DeletePushMirrors = deletePushMirrors + }, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + testCase.setup() + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreatePushMirrorOption{ + RemoteAddress: remoteAddress, + Interval: "8h", + }).AddTokenAuth(token) + + resp := MakeRequest(t, req, testCase.status) + if testCase.message != "" { + err := api.APIError{} + DecodeJSON(t, resp, &err) + assert.EqualValues(t, testCase.message, err.Message) + } + + req = NewRequest(t, "GET", urlStr).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var pushMirrors []*api.PushMirror + DecodeJSON(t, resp, &pushMirrors) + if assert.Len(t, pushMirrors, testCase.mirrorCount) && testCase.mirrorCount > 0 { + pushMirror := pushMirrors[0] + assert.EqualValues(t, remoteAddress, pushMirror.RemoteAddress) + + repo_model.DeletePushMirrors = deletePushMirrors + req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%s", urlStr, pushMirror.RemoteName)).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + } + }) + } +} + +func TestAPIPushMirrorSSH(t *testing.T) { + _, err := exec.LookPath("ssh") + if err != nil { + t.Skip("SSH executable not present") + } + + onGiteaRun(t, func(t *testing.T, _ *url.URL) { + defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)() + defer test.MockVariableValue(&setting.Mirror.Enabled, true)() + defer test.MockVariableValue(&setting.SSH.RootPath, t.TempDir())() + require.NoError(t, migrations.Init()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + srcRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + assert.False(t, srcRepo.HasWiki()) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + pushToRepo, _, f := tests.CreateDeclarativeRepoWithOptions(t, user, tests.DeclarativeRepoOptions{ + Name: optional.Some("push-mirror-test"), + AutoInit: optional.Some(false), + EnabledUnits: optional.Some([]unit.Type{unit.TypeCode}), + }) + defer f() + + sshURL := fmt.Sprintf("ssh://%s@%s/%s.git", setting.SSH.User, net.JoinHostPort(setting.SSH.ListenHost, strconv.Itoa(setting.SSH.ListenPort)), pushToRepo.FullName()) + + t.Run("Mutual exclusive", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName()), &api.CreatePushMirrorOption{ + RemoteAddress: sshURL, + Interval: "8h", + UseSSH: true, + RemoteUsername: "user", + RemotePassword: "password", + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusBadRequest) + + var apiError api.APIError + DecodeJSON(t, resp, &apiError) + assert.EqualValues(t, "'use_ssh' is mutually exclusive with 'remote_username' and 'remote_passoword'", apiError.Message) + }) + + t.Run("SSH not available", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&git.HasSSHExecutable, false)() + + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName()), &api.CreatePushMirrorOption{ + RemoteAddress: sshURL, + Interval: "8h", + UseSSH: true, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusBadRequest) + + var apiError api.APIError + DecodeJSON(t, resp, &apiError) + assert.EqualValues(t, "SSH authentication not available.", apiError.Message) + }) + + t.Run("Normal", func(t *testing.T) { + var pushMirror *repo_model.PushMirror + t.Run("Adding", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName()), &api.CreatePushMirrorOption{ + RemoteAddress: sshURL, + Interval: "8h", + UseSSH: true, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + pushMirror = unittest.AssertExistsAndLoadBean(t, &repo_model.PushMirror{RepoID: srcRepo.ID}) + assert.NotEmpty(t, pushMirror.PrivateKey) + assert.NotEmpty(t, pushMirror.PublicKey) + }) + + publickey := pushMirror.GetPublicKey() + t.Run("Publickey", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName())).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var pushMirrors []*api.PushMirror + DecodeJSON(t, resp, &pushMirrors) + assert.Len(t, pushMirrors, 1) + assert.EqualValues(t, publickey, pushMirrors[0].PublicKey) + }) + + t.Run("Add deploy key", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/keys", pushToRepo.FullName()), &api.CreateKeyOption{ + Title: "push mirror key", + Key: publickey, + ReadOnly: false, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "push mirror key", RepoID: pushToRepo.ID}) + }) + + t.Run("Synchronize", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors-sync", srcRepo.FullName())).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("Check mirrored content", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + sha := "1032bbf17fbc0d9c95bb5418dabe8f8c99278700" + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/commits?limit=1", srcRepo.FullName())).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var commitList []*api.Commit + DecodeJSON(t, resp, &commitList) + + assert.Len(t, commitList, 1) + assert.EqualValues(t, sha, commitList[0].SHA) + + assert.Eventually(t, func() bool { + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/commits?limit=1", srcRepo.FullName())).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var commitList []*api.Commit + DecodeJSON(t, resp, &commitList) + + return len(commitList) != 0 && commitList[0].SHA == sha + }, time.Second*30, time.Second) + }) + + t.Run("Check known host keys", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + knownHosts, err := os.ReadFile(filepath.Join(setting.SSH.RootPath, "known_hosts")) + require.NoError(t, err) + + publicKey, err := os.ReadFile(setting.SSH.ServerHostKeys[0] + ".pub") + require.NoError(t, err) + + assert.Contains(t, string(knownHosts), string(publicKey)) + }) + }) + }) +} diff --git a/tests/integration/api_quota_management_test.go b/tests/integration/api_quota_management_test.go new file mode 100644 index 0000000..6337e66 --- /dev/null +++ b/tests/integration/api_quota_management_test.go @@ -0,0 +1,846 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + quota_model "code.gitea.io/gitea/models/quota" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIQuotaDisabled(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Quota.Enabled, false)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) + session := loginUser(t, user.Name) + + req := NewRequest(t, "GET", "/api/v1/user/quota") + session.MakeRequest(t, req, http.StatusNotFound) +} + +func apiCreateUser(t *testing.T, username string) func() { + t.Helper() + + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) + session := loginUser(t, admin.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll) + + mustChangePassword := false + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/users", api.CreateUserOption{ + Email: "api+" + username + "@example.com", + Username: username, + Password: "password", + MustChangePassword: &mustChangePassword, + }).AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusCreated) + + return func() { + req := NewRequest(t, "DELETE", "/api/v1/admin/users/"+username+"?purge=true").AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusNoContent) + } +} + +func TestAPIQuotaCreateGroupWithRules(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Quota.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + // Create two rules in advance + unlimited := int64(-1) + defer createQuotaRule(t, api.CreateQuotaRuleOptions{ + Name: "unlimited", + Limit: &unlimited, + Subjects: []string{"size:all"}, + })() + zero := int64(0) + defer createQuotaRule(t, api.CreateQuotaRuleOptions{ + Name: "deny-git-lfs", + Limit: &zero, + Subjects: []string{"size:git:lfs"}, + })() + + // Log in as admin + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) + adminSession := loginUser(t, admin.Name) + adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll) + + // Create a new group, with rules specified + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{ + Name: "group-with-rules", + Rules: []api.CreateQuotaRuleOptions{ + // First: an existing group, unlimited, name only + { + Name: "unlimited", + }, + // Second: an existing group, deny-git-lfs, with different params + { + Name: "deny-git-lfs", + Limit: &unlimited, + }, + // Third: an entirely new group + { + Name: "new-rule", + Subjects: []string{"size:assets:all"}, + }, + }, + }).AddTokenAuth(adminToken) + resp := adminSession.MakeRequest(t, req, http.StatusCreated) + defer func() { + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/group-with-rules").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "DELETE", "/api/v1/admin/quota/rules/new-rule").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + }() + + // Verify that we created a group with rules included + var q api.QuotaGroup + DecodeJSON(t, resp, &q) + + assert.Equal(t, "group-with-rules", q.Name) + assert.Len(t, q.Rules, 3) + + // Verify that the previously existing rules are unchanged + rule, err := quota_model.GetRuleByName(db.DefaultContext, "unlimited") + require.NoError(t, err) + assert.NotNil(t, rule) + assert.EqualValues(t, -1, rule.Limit) + assert.EqualValues(t, quota_model.LimitSubjects{quota_model.LimitSubjectSizeAll}, rule.Subjects) + + rule, err = quota_model.GetRuleByName(db.DefaultContext, "deny-git-lfs") + require.NoError(t, err) + assert.NotNil(t, rule) + assert.EqualValues(t, 0, rule.Limit) + assert.EqualValues(t, quota_model.LimitSubjects{quota_model.LimitSubjectSizeGitLFS}, rule.Subjects) + + // Verify that the new rule was also created + rule, err = quota_model.GetRuleByName(db.DefaultContext, "new-rule") + require.NoError(t, err) + assert.NotNil(t, rule) + assert.EqualValues(t, 0, rule.Limit) + assert.EqualValues(t, quota_model.LimitSubjects{quota_model.LimitSubjectSizeAssetsAll}, rule.Subjects) + + t.Run("invalid rule spec", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{ + Name: "group-with-invalid-rule-spec", + Rules: []api.CreateQuotaRuleOptions{ + { + Name: "rule-with-wrong-spec", + Subjects: []string{"valid:false"}, + }, + }, + }).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity) + }) +} + +func TestAPIQuotaEmptyState(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Quota.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + username := "quota-empty-user" + defer apiCreateUser(t, username)() + session := loginUser(t, username) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll) + + t.Run("#/admin/users/quota-empty-user/quota", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) + adminSession := loginUser(t, admin.Name) + adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll) + + req := NewRequest(t, "GET", "/api/v1/admin/users/quota-empty-user/quota").AddTokenAuth(adminToken) + resp := adminSession.MakeRequest(t, req, http.StatusOK) + + var q api.QuotaInfo + DecodeJSON(t, resp, &q) + + assert.EqualValues(t, api.QuotaUsed{}, q.Used) + assert.Empty(t, q.Groups) + }) + + t.Run("#/user/quota", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user/quota").AddTokenAuth(token) + resp := session.MakeRequest(t, req, http.StatusOK) + + var q api.QuotaInfo + DecodeJSON(t, resp, &q) + + assert.EqualValues(t, api.QuotaUsed{}, q.Used) + assert.Empty(t, q.Groups) + + t.Run("#/user/quota/artifacts", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user/quota/artifacts").AddTokenAuth(token) + resp := session.MakeRequest(t, req, http.StatusOK) + + var q api.QuotaUsedArtifactList + DecodeJSON(t, resp, &q) + + assert.Empty(t, q) + }) + + t.Run("#/user/quota/attachments", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user/quota/attachments").AddTokenAuth(token) + resp := session.MakeRequest(t, req, http.StatusOK) + + var q api.QuotaUsedAttachmentList + DecodeJSON(t, resp, &q) + + assert.Empty(t, q) + }) + + t.Run("#/user/quota/packages", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user/quota/packages").AddTokenAuth(token) + resp := session.MakeRequest(t, req, http.StatusOK) + + var q api.QuotaUsedPackageList + DecodeJSON(t, resp, &q) + + assert.Empty(t, q) + }) + }) +} + +func createQuotaRule(t *testing.T, opts api.CreateQuotaRuleOptions) func() { + t.Helper() + + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) + adminSession := loginUser(t, admin.Name) + adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll) + + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", opts).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusCreated) + + return func() { + req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/rules/%s", opts.Name).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + } +} + +func createQuotaGroup(t *testing.T, name string) func() { + t.Helper() + + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) + adminSession := loginUser(t, admin.Name) + adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll) + + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{ + Name: name, + }).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusCreated) + + return func() { + req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/%s", name).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + } +} + +func TestAPIQuotaAdminRoutesRules(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Quota.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) + adminSession := loginUser(t, admin.Name) + adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll) + + zero := int64(0) + oneKb := int64(1024) + + t.Run("adminCreateQuotaRule", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", api.CreateQuotaRuleOptions{ + Name: "deny-all", + Limit: &zero, + Subjects: []string{"size:all"}, + }).AddTokenAuth(adminToken) + resp := adminSession.MakeRequest(t, req, http.StatusCreated) + defer func() { + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/rules/deny-all").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + }() + + var q api.QuotaRuleInfo + DecodeJSON(t, resp, &q) + + assert.Equal(t, "deny-all", q.Name) + assert.EqualValues(t, 0, q.Limit) + assert.EqualValues(t, []string{"size:all"}, q.Subjects) + + rule, err := quota_model.GetRuleByName(db.DefaultContext, "deny-all") + require.NoError(t, err) + assert.EqualValues(t, 0, rule.Limit) + + t.Run("unhappy path", func(t *testing.T) { + t.Run("missing options", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", nil).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + + t.Run("invalid subjects", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", api.CreateQuotaRuleOptions{ + Name: "invalid-subjects", + Limit: &zero, + Subjects: []string{"valid:false"}, + }).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + + t.Run("trying to add an existing rule", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + rule := api.CreateQuotaRuleOptions{ + Name: "double-rule", + Limit: &zero, + } + + defer createQuotaRule(t, rule)() + + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", rule).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusConflict) + }) + }) + }) + + t.Run("adminDeleteQuotaRule", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + createQuotaRule(t, api.CreateQuotaRuleOptions{ + Name: "deny-all", + Limit: &zero, + Subjects: []string{"size:all"}, + }) + + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/rules/deny-all").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + + rule, err := quota_model.GetRuleByName(db.DefaultContext, "deny-all") + require.NoError(t, err) + assert.Nil(t, rule) + + t.Run("unhappy path", func(t *testing.T) { + t.Run("nonexistent rule", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/rules/does-not-exist").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNotFound) + }) + }) + }) + + t.Run("adminEditQuotaRule", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + defer createQuotaRule(t, api.CreateQuotaRuleOptions{ + Name: "deny-all", + Limit: &zero, + Subjects: []string{"size:all"}, + })() + + req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/quota/rules/deny-all", api.EditQuotaRuleOptions{ + Limit: &oneKb, + }).AddTokenAuth(adminToken) + resp := adminSession.MakeRequest(t, req, http.StatusOK) + + var q api.QuotaRuleInfo + DecodeJSON(t, resp, &q) + assert.EqualValues(t, 1024, q.Limit) + + rule, err := quota_model.GetRuleByName(db.DefaultContext, "deny-all") + require.NoError(t, err) + assert.EqualValues(t, 1024, rule.Limit) + + t.Run("no options", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/quota/rules/deny-all", nil).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusOK) + }) + + t.Run("unhappy path", func(t *testing.T) { + t.Run("nonexistent rule", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/quota/rules/does-not-exist", api.EditQuotaRuleOptions{ + Limit: &oneKb, + }).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("invalid subjects", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/quota/rules/deny-all", api.EditQuotaRuleOptions{ + Subjects: &[]string{"valid:false"}, + }).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + }) + }) + + t.Run("adminListQuotaRules", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + defer createQuotaRule(t, api.CreateQuotaRuleOptions{ + Name: "deny-all", + Limit: &zero, + Subjects: []string{"size:all"}, + })() + + req := NewRequest(t, "GET", "/api/v1/admin/quota/rules").AddTokenAuth(adminToken) + resp := adminSession.MakeRequest(t, req, http.StatusOK) + + var rules []api.QuotaRuleInfo + DecodeJSON(t, resp, &rules) + + assert.Len(t, rules, 1) + assert.Equal(t, "deny-all", rules[0].Name) + assert.EqualValues(t, 0, rules[0].Limit) + }) +} + +func TestAPIQuotaAdminRoutesGroups(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Quota.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) + adminSession := loginUser(t, admin.Name) + adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll) + + zero := int64(0) + + ruleDenyAll := api.CreateQuotaRuleOptions{ + Name: "deny-all", + Limit: &zero, + Subjects: []string{"size:all"}, + } + + username := "quota-test-user" + defer apiCreateUser(t, username)() + + t.Run("adminCreateQuotaGroup", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{ + Name: "default", + }).AddTokenAuth(adminToken) + resp := adminSession.MakeRequest(t, req, http.StatusCreated) + defer func() { + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + }() + + var q api.QuotaGroup + DecodeJSON(t, resp, &q) + + assert.Equal(t, "default", q.Name) + assert.Empty(t, q.Rules) + + group, err := quota_model.GetGroupByName(db.DefaultContext, "default") + require.NoError(t, err) + assert.Equal(t, "default", group.Name) + assert.Empty(t, group.Rules) + + t.Run("unhappy path", func(t *testing.T) { + t.Run("missing options", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", nil).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + + t.Run("trying to add an existing group", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + defer createQuotaGroup(t, "duplicate")() + + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{ + Name: "duplicate", + }).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusConflict) + }) + }) + }) + + t.Run("adminDeleteQuotaGroup", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + createQuotaGroup(t, "default") + + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + + group, err := quota_model.GetGroupByName(db.DefaultContext, "default") + require.NoError(t, err) + assert.Nil(t, group) + + t.Run("unhappy path", func(t *testing.T) { + t.Run("non-existing group", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/does-not-exist").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNotFound) + }) + }) + }) + + t.Run("adminAddRuleToQuotaGroup", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer createQuotaGroup(t, "default")() + defer createQuotaRule(t, ruleDenyAll)() + + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + + group, err := quota_model.GetGroupByName(db.DefaultContext, "default") + require.NoError(t, err) + assert.Len(t, group.Rules, 1) + assert.Equal(t, "deny-all", group.Rules[0].Name) + + t.Run("unhappy path", func(t *testing.T) { + t.Run("non-existing group", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/does-not-exist/rules/deny-all").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("non-existing rule", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/does-not-exist").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNotFound) + }) + }) + }) + + t.Run("adminRemoveRuleFromQuotaGroup", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer createQuotaGroup(t, "default")() + defer createQuotaRule(t, ruleDenyAll)() + + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + + group, err := quota_model.GetGroupByName(db.DefaultContext, "default") + require.NoError(t, err) + assert.Equal(t, "default", group.Name) + assert.Empty(t, group.Rules) + + t.Run("unhappy path", func(t *testing.T) { + t.Run("non-existing group", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/does-not-exist/rules/deny-all").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("non-existing rule", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/rules/does-not-exist").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("rule not in group", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer createQuotaRule(t, api.CreateQuotaRuleOptions{ + Name: "rule-not-in-group", + Limit: &zero, + })() + + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/rules/rule-not-in-group").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNotFound) + }) + }) + }) + + t.Run("adminGetQuotaGroup", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer createQuotaGroup(t, "default")() + defer createQuotaRule(t, ruleDenyAll)() + + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "GET", "/api/v1/admin/quota/groups/default").AddTokenAuth(adminToken) + resp := adminSession.MakeRequest(t, req, http.StatusOK) + + var q api.QuotaGroup + DecodeJSON(t, resp, &q) + + assert.Equal(t, "default", q.Name) + assert.Len(t, q.Rules, 1) + assert.Equal(t, "deny-all", q.Rules[0].Name) + + t.Run("unhappy path", func(t *testing.T) { + t.Run("non-existing group", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/admin/quota/groups/does-not-exist").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNotFound) + }) + }) + }) + + t.Run("adminListQuotaGroups", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer createQuotaGroup(t, "default")() + defer createQuotaRule(t, ruleDenyAll)() + + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "GET", "/api/v1/admin/quota/groups").AddTokenAuth(adminToken) + resp := adminSession.MakeRequest(t, req, http.StatusOK) + + var q api.QuotaGroupList + DecodeJSON(t, resp, &q) + + assert.Len(t, q, 1) + assert.Equal(t, "default", q[0].Name) + assert.Len(t, q[0].Rules, 1) + assert.Equal(t, "deny-all", q[0].Rules[0].Name) + }) + + t.Run("adminAddUserToQuotaGroup", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer createQuotaGroup(t, "default")() + + req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/default/users/%s", username).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username}) + + groups, err := quota_model.GetGroupsForUser(db.DefaultContext, user.ID) + require.NoError(t, err) + assert.Len(t, groups, 1) + assert.Equal(t, "default", groups[0].Name) + + t.Run("unhappy path", func(t *testing.T) { + t.Run("non-existing group", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/does-not-exist/users/%s", username).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("non-existing user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/users/this-user-does-not-exist").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("user already added", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/users/user1").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/users/user1").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusConflict) + }) + }) + }) + + t.Run("adminRemoveUserFromQuotaGroup", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer createQuotaGroup(t, "default")() + + req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/default/users/%s", username).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + + req = NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/default/users/%s", username).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username}) + groups, err := quota_model.GetGroupsForUser(db.DefaultContext, user.ID) + require.NoError(t, err) + assert.Empty(t, groups) + + t.Run("unhappy path", func(t *testing.T) { + t.Run("non-existing group", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/does-not-exist/users/%s", username).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("non-existing user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/users/does-not-exist").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("user not in group", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/users/user1").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNotFound) + }) + }) + }) + + t.Run("adminListUsersInQuotaGroup", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer createQuotaGroup(t, "default")() + + req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/default/users/%s", username).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "GET", "/api/v1/admin/quota/groups/default/users").AddTokenAuth(adminToken) + resp := adminSession.MakeRequest(t, req, http.StatusOK) + + var q []api.User + DecodeJSON(t, resp, &q) + + assert.Len(t, q, 1) + assert.Equal(t, username, q[0].UserName) + + t.Run("unhappy path", func(t *testing.T) { + t.Run("non-existing group", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/admin/quota/groups/does-not-exist/users").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNotFound) + }) + }) + }) + + t.Run("adminSetUserQuotaGroups", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer createQuotaGroup(t, "default")() + defer createQuotaGroup(t, "test-1")() + defer createQuotaGroup(t, "test-2")() + + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/admin/users/%s/quota/groups", username), api.SetUserQuotaGroupsOptions{ + Groups: &[]string{"default", "test-1", "test-2"}, + }).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username}) + + groups, err := quota_model.GetGroupsForUser(db.DefaultContext, user.ID) + require.NoError(t, err) + assert.Len(t, groups, 3) + + t.Run("unhappy path", func(t *testing.T) { + t.Run("non-existing user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", "/api/v1/admin/users/does-not-exist/quota/groups", api.SetUserQuotaGroupsOptions{ + Groups: &[]string{"default", "test-1", "test-2"}, + }).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("non-existing group", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/admin/users/%s/quota/groups", username), api.SetUserQuotaGroupsOptions{ + Groups: &[]string{"default", "test-1", "test-2", "this-group-does-not-exist"}, + }).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + }) + }) +} + +func TestAPIQuotaUserRoutes(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Quota.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) + adminSession := loginUser(t, admin.Name) + adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll) + + // Create a test user + username := "quota-test-user-routes" + defer apiCreateUser(t, username)() + session := loginUser(t, username) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll) + + // Set up rules & groups for the user + defer createQuotaGroup(t, "user-routes-deny")() + defer createQuotaGroup(t, "user-routes-1kb")() + + zero := int64(0) + ruleDenyAll := api.CreateQuotaRuleOptions{ + Name: "user-routes-deny-all", + Limit: &zero, + Subjects: []string{"size:all"}, + } + defer createQuotaRule(t, ruleDenyAll)() + oneKb := int64(1024) + rule1KbStuff := api.CreateQuotaRuleOptions{ + Name: "user-routes-1kb", + Limit: &oneKb, + Subjects: []string{"size:assets:attachments:releases", "size:assets:packages:all", "size:git:lfs"}, + } + defer createQuotaRule(t, rule1KbStuff)() + + req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/user-routes-deny/rules/user-routes-deny-all").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + req = NewRequest(t, "PUT", "/api/v1/admin/quota/groups/user-routes-1kb/rules/user-routes-1kb").AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + + req = NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/user-routes-deny/users/%s", username).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + req = NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/user-routes-1kb/users/%s", username).AddTokenAuth(adminToken) + adminSession.MakeRequest(t, req, http.StatusNoContent) + + t.Run("userGetQuota", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user/quota").AddTokenAuth(token) + resp := session.MakeRequest(t, req, http.StatusOK) + + var q api.QuotaInfo + DecodeJSON(t, resp, &q) + + assert.Len(t, q.Groups, 2) + assert.Len(t, q.Groups[0].Rules, 1) + assert.Len(t, q.Groups[1].Rules, 1) + }) +} diff --git a/tests/integration/api_quota_use_test.go b/tests/integration/api_quota_use_test.go new file mode 100644 index 0000000..11cbdcf --- /dev/null +++ b/tests/integration/api_quota_use_test.go @@ -0,0 +1,1436 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + quota_model "code.gitea.io/gitea/models/quota" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/migration" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + repo_service "code.gitea.io/gitea/services/repository" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type quotaEnvUser struct { + User *user_model.User + Session *TestSession + Token string +} + +type quotaEnvOrgs struct { + Unlimited api.Organization + Limited api.Organization +} + +type quotaEnv struct { + Admin quotaEnvUser + User quotaEnvUser + Dummy quotaEnvUser + + Repo *repo_model.Repository + Orgs quotaEnvOrgs + + cleanups []func() +} + +func (e *quotaEnv) APIPathForRepo(uriFormat string, a ...any) string { + path := fmt.Sprintf(uriFormat, a...) + return fmt.Sprintf("/api/v1/repos/%s/%s%s", e.User.User.Name, e.Repo.Name, path) +} + +func (e *quotaEnv) Cleanup() { + for i := len(e.cleanups) - 1; i >= 0; i-- { + e.cleanups[i]() + } +} + +func (e *quotaEnv) WithoutQuota(t *testing.T, task func(), rules ...string) { + rule := "all" + if rules != nil { + rule = rules[0] + } + defer e.SetRuleLimit(t, rule, -1)() + task() +} + +func (e *quotaEnv) SetupWithSingleQuotaRule(t *testing.T) { + t.Helper() + + cleaner := test.MockVariableValue(&setting.Quota.Enabled, true) + e.cleanups = append(e.cleanups, cleaner) + cleaner = test.MockVariableValue(&testWebRoutes, routers.NormalRoutes()) + e.cleanups = append(e.cleanups, cleaner) + + // Create a default group + cleaner = createQuotaGroup(t, "default") + e.cleanups = append(e.cleanups, cleaner) + + // Create a single all-encompassing rule + unlimited := int64(-1) + ruleAll := api.CreateQuotaRuleOptions{ + Name: "all", + Limit: &unlimited, + Subjects: []string{"size:all"}, + } + cleaner = createQuotaRule(t, ruleAll) + e.cleanups = append(e.cleanups, cleaner) + + // Add these rules to the group + cleaner = e.AddRuleToGroup(t, "default", "all") + e.cleanups = append(e.cleanups, cleaner) + + // Add the user to the quota group + cleaner = e.AddUserToGroup(t, "default", e.User.User.Name) + e.cleanups = append(e.cleanups, cleaner) +} + +func (e *quotaEnv) AddDummyUser(t *testing.T, username string) { + t.Helper() + + userCleanup := apiCreateUser(t, username) + e.Dummy.User = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username}) + e.Dummy.Session = loginUser(t, e.Dummy.User.Name) + e.Dummy.Token = getTokenForLoggedInUser(t, e.Dummy.Session, auth_model.AccessTokenScopeAll) + e.cleanups = append(e.cleanups, userCleanup) + + // Add the user to the "limited" group. See AddLimitedOrg + cleaner := e.AddUserToGroup(t, "limited", username) + e.cleanups = append(e.cleanups, cleaner) +} + +func (e *quotaEnv) AddLimitedOrg(t *testing.T) { + t.Helper() + + // Create the limited org + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", api.CreateOrgOption{ + UserName: "limited-org", + }).AddTokenAuth(e.User.Token) + resp := e.User.Session.MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, &e.Orgs.Limited) + e.cleanups = append(e.cleanups, func() { + req := NewRequest(t, "DELETE", "/api/v1/orgs/limited-org"). + AddTokenAuth(e.Admin.Token) + e.Admin.Session.MakeRequest(t, req, http.StatusNoContent) + }) + + // Create a group for the org + cleaner := createQuotaGroup(t, "limited") + e.cleanups = append(e.cleanups, cleaner) + + // Create a single all-encompassing rule + zero := int64(0) + ruleDenyAll := api.CreateQuotaRuleOptions{ + Name: "deny-all", + Limit: &zero, + Subjects: []string{"size:all"}, + } + cleaner = createQuotaRule(t, ruleDenyAll) + e.cleanups = append(e.cleanups, cleaner) + + // Add these rules to the group + cleaner = e.AddRuleToGroup(t, "limited", "deny-all") + e.cleanups = append(e.cleanups, cleaner) + + // Add the user to the quota group + cleaner = e.AddUserToGroup(t, "limited", e.Orgs.Limited.UserName) + e.cleanups = append(e.cleanups, cleaner) +} + +func (e *quotaEnv) AddUnlimitedOrg(t *testing.T) { + t.Helper() + + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", api.CreateOrgOption{ + UserName: "unlimited-org", + }).AddTokenAuth(e.User.Token) + resp := e.User.Session.MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, &e.Orgs.Unlimited) + e.cleanups = append(e.cleanups, func() { + req := NewRequest(t, "DELETE", "/api/v1/orgs/unlimited-org"). + AddTokenAuth(e.Admin.Token) + e.Admin.Session.MakeRequest(t, req, http.StatusNoContent) + }) +} + +func (e *quotaEnv) SetupWithMultipleQuotaRules(t *testing.T) { + t.Helper() + + cleaner := test.MockVariableValue(&setting.Quota.Enabled, true) + e.cleanups = append(e.cleanups, cleaner) + cleaner = test.MockVariableValue(&testWebRoutes, routers.NormalRoutes()) + e.cleanups = append(e.cleanups, cleaner) + + // Create a default group + cleaner = createQuotaGroup(t, "default") + e.cleanups = append(e.cleanups, cleaner) + + // Create three rules: all, repo-size, and asset-size + zero := int64(0) + ruleAll := api.CreateQuotaRuleOptions{ + Name: "all", + Limit: &zero, + Subjects: []string{"size:all"}, + } + cleaner = createQuotaRule(t, ruleAll) + e.cleanups = append(e.cleanups, cleaner) + + fifteenMb := int64(1024 * 1024 * 15) + ruleRepoSize := api.CreateQuotaRuleOptions{ + Name: "repo-size", + Limit: &fifteenMb, + Subjects: []string{"size:repos:all"}, + } + cleaner = createQuotaRule(t, ruleRepoSize) + e.cleanups = append(e.cleanups, cleaner) + + ruleAssetSize := api.CreateQuotaRuleOptions{ + Name: "asset-size", + Limit: &fifteenMb, + Subjects: []string{"size:assets:all"}, + } + cleaner = createQuotaRule(t, ruleAssetSize) + e.cleanups = append(e.cleanups, cleaner) + + // Add these rules to the group + cleaner = e.AddRuleToGroup(t, "default", "all") + e.cleanups = append(e.cleanups, cleaner) + cleaner = e.AddRuleToGroup(t, "default", "repo-size") + e.cleanups = append(e.cleanups, cleaner) + cleaner = e.AddRuleToGroup(t, "default", "asset-size") + e.cleanups = append(e.cleanups, cleaner) + + // Add the user to the quota group + cleaner = e.AddUserToGroup(t, "default", e.User.User.Name) + e.cleanups = append(e.cleanups, cleaner) +} + +func (e *quotaEnv) AddUserToGroup(t *testing.T, group, user string) func() { + t.Helper() + + req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/%s/users/%s", group, user).AddTokenAuth(e.Admin.Token) + e.Admin.Session.MakeRequest(t, req, http.StatusNoContent) + + return func() { + req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/%s/users/%s", group, user).AddTokenAuth(e.Admin.Token) + e.Admin.Session.MakeRequest(t, req, http.StatusNoContent) + } +} + +func (e *quotaEnv) SetRuleLimit(t *testing.T, rule string, limit int64) func() { + t.Helper() + + originalRule, err := quota_model.GetRuleByName(db.DefaultContext, rule) + require.NoError(t, err) + assert.NotNil(t, originalRule) + + req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/admin/quota/rules/%s", rule), api.EditQuotaRuleOptions{ + Limit: &limit, + }).AddTokenAuth(e.Admin.Token) + e.Admin.Session.MakeRequest(t, req, http.StatusOK) + + return func() { + e.SetRuleLimit(t, rule, originalRule.Limit) + } +} + +func (e *quotaEnv) RemoveRuleFromGroup(t *testing.T, group, rule string) { + t.Helper() + + req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/%s/rules/%s", group, rule).AddTokenAuth(e.Admin.Token) + e.Admin.Session.MakeRequest(t, req, http.StatusNoContent) +} + +func (e *quotaEnv) AddRuleToGroup(t *testing.T, group, rule string) func() { + t.Helper() + + req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/%s/rules/%s", group, rule).AddTokenAuth(e.Admin.Token) + e.Admin.Session.MakeRequest(t, req, http.StatusNoContent) + + return func() { + e.RemoveRuleFromGroup(t, group, rule) + } +} + +func prepareQuotaEnv(t *testing.T, username string) *quotaEnv { + t.Helper() + + env := quotaEnv{} + + // Set up the admin user + env.Admin.User = unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) + env.Admin.Session = loginUser(t, env.Admin.User.Name) + env.Admin.Token = getTokenForLoggedInUser(t, env.Admin.Session, auth_model.AccessTokenScopeAll) + + // Create a test user + userCleanup := apiCreateUser(t, username) + env.User.User = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username}) + env.User.Session = loginUser(t, env.User.User.Name) + env.User.Token = getTokenForLoggedInUser(t, env.User.Session, auth_model.AccessTokenScopeAll) + env.cleanups = append(env.cleanups, userCleanup) + + // Create a repository + repo, _, repoCleanup := tests.CreateDeclarativeRepoWithOptions(t, env.User.User, tests.DeclarativeRepoOptions{}) + env.Repo = repo + env.cleanups = append(env.cleanups, repoCleanup) + + return &env +} + +func TestAPIQuotaUserCleanSlate(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + defer test.MockVariableValue(&setting.Quota.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + env := prepareQuotaEnv(t, "qt-clean-slate") + defer env.Cleanup() + + t.Run("branch creation", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create a branch + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{ + BranchName: "branch-to-delete", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + }) +} + +func TestAPIQuotaEnforcement(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + testAPIQuotaEnforcement(t) + }) +} + +func TestAPIQuotaCountsTowardsCorrectUser(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := prepareQuotaEnv(t, "quota-correct-user-test") + defer env.Cleanup() + env.SetupWithSingleQuotaRule(t) + + // Create a new group, with size:all set to 0 + defer createQuotaGroup(t, "limited")() + zero := int64(0) + defer createQuotaRule(t, api.CreateQuotaRuleOptions{ + Name: "limited", + Limit: &zero, + Subjects: []string{"size:all"}, + })() + defer env.AddRuleToGroup(t, "limited", "limited")() + + // Add the admin user to it + defer env.AddUserToGroup(t, "limited", env.Admin.User.Name)() + + // Add the admin user as collaborator to our repo + perm := "admin" + req := NewRequestWithJSON(t, "PUT", + env.APIPathForRepo("/collaborators/%s", env.Admin.User.Name), + api.AddCollaboratorOption{ + Permission: &perm, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + + // Now, try to push something as admin! + req = NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{ + BranchName: "admin-branch", + }).AddTokenAuth(env.Admin.Token) + env.Admin.Session.MakeRequest(t, req, http.StatusCreated) + }) +} + +func TestAPIQuotaError(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := prepareQuotaEnv(t, "quota-enforcement") + defer env.Cleanup() + env.SetupWithSingleQuotaRule(t) + env.AddUnlimitedOrg(t) + env.AddLimitedOrg(t) + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{ + Organization: &env.Orgs.Limited.UserName, + }).AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + + var msg context.APIQuotaExceeded + DecodeJSON(t, resp, &msg) + + assert.EqualValues(t, env.Orgs.Limited.ID, msg.UserID) + assert.Equal(t, env.Orgs.Limited.UserName, msg.UserName) + }) +} + +func testAPIQuotaEnforcement(t *testing.T) { + env := prepareQuotaEnv(t, "quota-enforcement") + defer env.Cleanup() + env.SetupWithSingleQuotaRule(t) + env.AddUnlimitedOrg(t) + env.AddLimitedOrg(t) + env.AddDummyUser(t, "qe-dummy") + + t.Run("#/user/repos", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer env.SetRuleLimit(t, "all", 0)() + + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", api.CreateRepoOption{ + Name: "quota-exceeded", + AutoInit: true, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user/repos").AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + }) + + t.Run("#/orgs/{org}/repos", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer env.SetRuleLimit(t, "all", 0) + + assertCreateRepo := func(t *testing.T, orgName, repoName string, expectedStatus int) func() { + t.Helper() + + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName), api.CreateRepoOption{ + Name: repoName, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, expectedStatus) + + return func() { + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s", orgName, repoName). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + } + } + + t.Run("limited", func(t *testing.T) { + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", env.Orgs.Unlimited.UserName). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assertCreateRepo(t, env.Orgs.Limited.UserName, "test-repo", http.StatusRequestEntityTooLarge) + }) + }) + + t.Run("unlimited", func(t *testing.T) { + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + defer assertCreateRepo(t, env.Orgs.Unlimited.UserName, "test-repo", http.StatusCreated)() + }) + }) + }) + + t.Run("#/repos/migrate", func(t *testing.T) { + t.Run("to:limited", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer env.SetRuleLimit(t, "all", 0)() + + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/migrate", api.MigrateRepoOptions{ + CloneAddr: env.Repo.HTMLURL() + ".git", + RepoName: "quota-migrate", + Service: "forgejo", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("to:unlimited", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer env.SetRuleLimit(t, "all", 0)() + + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/migrate", api.MigrateRepoOptions{ + CloneAddr: "an-invalid-address", + RepoName: "quota-migrate", + RepoOwner: env.Orgs.Unlimited.UserName, + Service: "forgejo", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + }) + + t.Run("#/repos/{template_owner}/{template_repo}/generate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create a template repository + template, _, cleanup := tests.CreateDeclarativeRepoWithOptions(t, env.User.User, tests.DeclarativeRepoOptions{ + IsTemplate: optional.Some(true), + }) + defer cleanup() + + // Drop the quota to 0 + defer env.SetRuleLimit(t, "all", 0)() + + t.Run("to: limited", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", template.APIURL()+"/generate", api.GenerateRepoOption{ + Owner: env.User.User.Name, + Name: "generated-repo", + GitContent: true, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("to: unlimited", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", template.APIURL()+"/generate", api.GenerateRepoOption{ + Owner: env.Orgs.Unlimited.UserName, + Name: "generated-repo", + GitContent: true, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + + req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/generated-repo", env.Orgs.Unlimited.UserName). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) + + t.Run("#/repos/{username}/{reponame}", func(t *testing.T) { + // Lets create a new repo to play with. + repo, _, repoCleanup := tests.CreateDeclarativeRepoWithOptions(t, env.User.User, tests.DeclarativeRepoOptions{}) + defer repoCleanup() + + // Drop the quota to 0 + defer env.SetRuleLimit(t, "all", 0)() + + deleteRepo := func(t *testing.T, path string) { + t.Helper() + + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s", path). + AddTokenAuth(env.Admin.Token) + env.Admin.Session.MakeRequest(t, req, http.StatusNoContent) + } + + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s", env.User.User.Name, repo.Name). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("PATCH", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + desc := "Some description" + req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", env.User.User.Name, repo.Name), api.EditRepoOption{ + Description: &desc, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s", env.User.User.Name, repo.Name). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + + t.Run("branches", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create a branch we can delete later + env.WithoutQuota(t, func() { + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{ + BranchName: "to-delete", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/branches")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{ + BranchName: "quota-exceeded", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("{branch}", func(t *testing.T) { + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/branches/to-delete")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", env.APIPathForRepo("/branches/to-delete")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) + }) + + t.Run("contents", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + var fileSha string + + // Create a file to play with + env.WithoutQuota(t, func() { + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/contents/plaything.txt"), api.CreateFileOptions{ + ContentBase64: base64.StdEncoding.EncodeToString([]byte("hello world")), + }).AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + + var r api.FileResponse + DecodeJSON(t, resp, &r) + + fileSha = r.Content.SHA + }) + + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/contents")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/contents"), api.ChangeFilesOptions{ + Files: []*api.ChangeFileOperation{ + { + Operation: "create", + Path: "quota-exceeded.txt", + }, + }, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("{filepath}", func(t *testing.T) { + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/contents/plaything.txt")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/contents/plaything.txt"), api.CreateFileOptions{ + ContentBase64: base64.StdEncoding.EncodeToString([]byte("hello world")), + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + t.Run("UPDATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PUT", env.APIPathForRepo("/contents/plaything.txt"), api.UpdateFileOptions{ + ContentBase64: base64.StdEncoding.EncodeToString([]byte("hello world")), + DeleteFileOptions: api.DeleteFileOptions{ + SHA: fileSha, + }, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Deleting a file fails, because it creates a new commit, + // which would increase the quota use. + req := NewRequestWithJSON(t, "DELETE", env.APIPathForRepo("/contents/plaything.txt"), api.DeleteFileOptions{ + SHA: fileSha, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + }) + }) + + t.Run("diffpatch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PUT", env.APIPathForRepo("/contents/README.md"), api.UpdateFileOptions{ + ContentBase64: base64.StdEncoding.EncodeToString([]byte("hello world")), + DeleteFileOptions: api.DeleteFileOptions{ + SHA: "c0ffeebabe", + }, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("forks", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("as: limited user", func(t *testing.T) { + // Our current user (env.User) is already limited here. + + t.Run("into: limited org", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{ + Organization: &env.Orgs.Limited.UserName, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("into: unlimited org", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{ + Organization: &env.Orgs.Unlimited.UserName, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusAccepted) + + deleteRepo(t, env.Orgs.Unlimited.UserName+"/"+env.Repo.Name) + }) + }) + t.Run("as: unlimited user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Lift the quota limits on our current user temporarily + defer env.SetRuleLimit(t, "all", -1)() + + t.Run("into: limited org", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{ + Organization: &env.Orgs.Limited.UserName, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("into: unlimited org", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{ + Organization: &env.Orgs.Unlimited.UserName, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusAccepted) + + deleteRepo(t, env.Orgs.Unlimited.UserName+"/"+env.Repo.Name) + }) + }) + }) + + t.Run("mirror-sync", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + var mirrorRepo *repo_model.Repository + env.WithoutQuota(t, func() { + // Create a mirror repo + opts := migration.MigrateOptions{ + RepoName: "test_mirror", + Description: "Test mirror", + Private: false, + Mirror: true, + CloneAddr: repo_model.RepoPath(env.User.User.Name, env.Repo.Name), + Wiki: true, + Releases: false, + } + + repo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, env.User.User, env.User.User, repo_service.CreateRepoOptions{ + Name: opts.RepoName, + Description: opts.Description, + IsPrivate: opts.Private, + IsMirror: opts.Mirror, + Status: repo_model.RepositoryBeingMigrated, + }) + require.NoError(t, err) + + mirrorRepo = repo + }) + + req := NewRequestf(t, "POST", "/api/v1/repos/%s/mirror-sync", mirrorRepo.FullName()). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("issues", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create an issue play with + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/issues"), api.CreateIssueOption{ + Title: "quota test issue", + }).AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + + var issue api.Issue + DecodeJSON(t, resp, &issue) + + createAsset := func(filename string) (*bytes.Buffer, string) { + buff := generateImg() + body := &bytes.Buffer{} + + // Setup multi-part + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("attachment", filename) + io.Copy(part, &buff) + writer.Close() + + return body, writer.FormDataContentType() + } + + t.Run("{index}/assets", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/issues/%d/assets", issue.Index)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + body, contentType := createAsset("overquota.png") + req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/issues/%d/assets", issue.Index), body). + AddTokenAuth(env.User.Token) + req.Header.Add("Content-Type", contentType) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("{attachment_id}", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + var issueAsset api.Attachment + env.WithoutQuota(t, func() { + body, contentType := createAsset("test.png") + req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/issues/%d/assets", issue.Index), body). + AddTokenAuth(env.User.Token) + req.Header.Add("Content-Type", contentType) + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + + DecodeJSON(t, resp, &issueAsset) + }) + + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/issues/%d/assets/%d", issue.Index, issueAsset.ID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("UPDATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PATCH", env.APIPathForRepo("/issues/%d/assets/%d", issue.Index, issueAsset.ID), api.EditAttachmentOptions{ + Name: "new-name.png", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", env.APIPathForRepo("/issues/%d/assets/%d", issue.Index, issueAsset.ID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) + }) + + t.Run("comments/{id}/assets", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create a new comment! + var comment api.Comment + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/issues/%d/comments", issue.Index), api.CreateIssueCommentOption{ + Body: "This is a comment", + }).AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, &comment) + + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/issues/comments/%d/assets", comment.ID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + body, contentType := createAsset("overquota.png") + req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/issues/comments/%d/assets", comment.ID), body). + AddTokenAuth(env.User.Token) + req.Header.Add("Content-Type", contentType) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("{attachment_id}", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + var attachment api.Attachment + env.WithoutQuota(t, func() { + body, contentType := createAsset("test.png") + req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/issues/comments/%d/assets", comment.ID), body). + AddTokenAuth(env.User.Token) + req.Header.Add("Content-Type", contentType) + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, &attachment) + }) + + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/issues/comments/%d/assets/%d", comment.ID, attachment.ID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("UPDATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PATCH", env.APIPathForRepo("/issues/comments/%d/assets/%d", comment.ID, attachment.ID), api.EditAttachmentOptions{ + Name: "new-name.png", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", env.APIPathForRepo("/issues/comments/%d/assets/%d", comment.ID, attachment.ID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) + }) + }) + + t.Run("pulls", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Fork the repository into the unlimited org first + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{ + Organization: &env.Orgs.Unlimited.UserName, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusAccepted) + + defer deleteRepo(t, env.Orgs.Unlimited.UserName+"/"+env.Repo.Name) + + // Create a pull request! + // + // Creating a pull request this way does not increase the space of + // the base repo, so is not subject to quota enforcement. + + req = NewRequestWithJSON(t, "POST", env.APIPathForRepo("/pulls"), api.CreatePullRequestOption{ + Base: "main", + Title: "test-pr", + Head: fmt.Sprintf("%s:main", env.Orgs.Unlimited.UserName), + }).AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + + var pr api.PullRequest + DecodeJSON(t, resp, &pr) + + t.Run("{index}", func(t *testing.T) { + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/pulls/%d", pr.Index)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("UPDATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PATCH", env.APIPathForRepo("/pulls/%d", pr.Index), api.EditPullRequestOption{ + Title: "Updated title", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + + t.Run("merge", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/pulls/%d/merge", pr.Index), forms.MergePullRequestForm{ + Do: "merge", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + }) + }) + + t.Run("releases", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + var releaseID int64 + + // Create a release so that there's something to play with. + env.WithoutQuota(t, func() { + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/releases"), api.CreateReleaseOption{ + TagName: "play-release-tag", + Title: "play-release", + }).AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + + var q api.Release + DecodeJSON(t, resp, &q) + + releaseID = q.ID + }) + + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/releases")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/releases"), api.CreateReleaseOption{ + TagName: "play-release-tag-two", + Title: "play-release-two", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("tags/{tag}", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create a release for our subtests + env.WithoutQuota(t, func() { + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/releases"), api.CreateReleaseOption{ + TagName: "play-release-tag-subtest", + Title: "play-release-subtest", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/releases/tags/play-release-tag-subtest")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", env.APIPathForRepo("/releases/tags/play-release-tag-subtest")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) + + t.Run("{id}", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + var tmpReleaseID int64 + + // Create a release so that there's something to play with. + env.WithoutQuota(t, func() { + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/releases"), api.CreateReleaseOption{ + TagName: "tmp-tag", + Title: "tmp-release", + }).AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + + var q api.Release + DecodeJSON(t, resp, &q) + + tmpReleaseID = q.ID + }) + + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/releases/%d", tmpReleaseID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("UPDATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PATCH", env.APIPathForRepo("/releases/%d", tmpReleaseID), api.EditReleaseOption{ + TagName: "tmp-tag-two", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", env.APIPathForRepo("/releases/%d", tmpReleaseID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + + t.Run("assets", func(t *testing.T) { + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/releases/%d/assets", releaseID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + body := strings.NewReader("hello world") + req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/releases/%d/assets?name=bar.txt", releaseID), body). + AddTokenAuth(env.User.Token) + req.Header.Add("Content-Type", "text/plain") + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("{attachment_id}", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + var attachmentID int64 + + // Create an attachment to play with + env.WithoutQuota(t, func() { + body := strings.NewReader("hello world") + req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/releases/%d/assets?name=foo.txt", releaseID), body). + AddTokenAuth(env.User.Token) + req.Header.Add("Content-Type", "text/plain") + resp := env.User.Session.MakeRequest(t, req, http.StatusCreated) + + var q api.Attachment + DecodeJSON(t, resp, &q) + + attachmentID = q.ID + }) + + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/releases/%d/assets/%d", releaseID, attachmentID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("UPDATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "PATCH", env.APIPathForRepo("/releases/%d/assets/%d", releaseID, attachmentID), api.EditAttachmentOptions{ + Name: "new-name.txt", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", env.APIPathForRepo("/releases/%d/assets/%d", releaseID, attachmentID)). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) + }) + }) + }) + + t.Run("tags", func(t *testing.T) { + t.Run("LIST", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/tags")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/tags"), api.CreateTagOption{ + TagName: "tag-quota-test", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("{tag}", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + env.WithoutQuota(t, func() { + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/tags"), api.CreateTagOption{ + TagName: "tag-quota-test-2", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", env.APIPathForRepo("/tags/tag-quota-test-2")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", env.APIPathForRepo("/tags/tag-quota-test-2")). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) + }) + + t.Run("transfer", func(t *testing.T) { + t.Run("to: limited", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create a repository to transfer + repo, _, cleanup := tests.CreateDeclarativeRepoWithOptions(t, env.User.User, tests.DeclarativeRepoOptions{}) + defer cleanup() + + // Initiate repo transfer + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer", env.User.User.Name, repo.Name), api.TransferRepoOption{ + NewOwner: env.Dummy.User.Name, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + + // Initiate it outside of quotas, so we can test accept/reject. + env.WithoutQuota(t, func() { + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer", env.User.User.Name, repo.Name), api.TransferRepoOption{ + NewOwner: env.Dummy.User.Name, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }, "deny-all") // a bit of a hack, sorry! + + // Try to accept the repo transfer + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/accept", env.User.User.Name, repo.Name)). + AddTokenAuth(env.Dummy.Token) + env.Dummy.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + + // Then reject it. + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject", env.User.User.Name, repo.Name)). + AddTokenAuth(env.Dummy.Token) + env.Dummy.Session.MakeRequest(t, req, http.StatusOK) + }) + + t.Run("to: unlimited", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Disable the quota for the dummy user + defer env.SetRuleLimit(t, "deny-all", -1)() + + // Create a repository to transfer + repo, _, cleanup := tests.CreateDeclarativeRepoWithOptions(t, env.User.User, tests.DeclarativeRepoOptions{}) + defer cleanup() + + // Initiate repo transfer + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer", env.User.User.Name, repo.Name), api.TransferRepoOption{ + NewOwner: env.Dummy.User.Name, + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + + // Accept the repo transfer + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/accept", env.User.User.Name, repo.Name)). + AddTokenAuth(env.Dummy.Token) + env.Dummy.Session.MakeRequest(t, req, http.StatusAccepted) + }) + }) + }) + + t.Run("#/packages/{owner}/{type}/{name}/{version}", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer env.SetRuleLimit(t, "all", 0)() + + // Create a generic package to play with + env.WithoutQuota(t, func() { + body := strings.NewReader("forgejo is awesome") + req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/packages/%s/generic/quota-test/1.0.0/test.txt", env.User.User.Name), body). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + }) + + t.Run("CREATE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + body := strings.NewReader("forgejo is awesome") + req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/packages/%s/generic/quota-test/1.0.0/overquota.txt", env.User.User.Name), body). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("GET", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestf(t, "GET", "/api/v1/packages/%s/generic/quota-test/1.0.0", env.User.User.Name). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusOK) + }) + t.Run("DELETE", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestf(t, "DELETE", "/api/v1/packages/%s/generic/quota-test/1.0.0", env.User.User.Name). + AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) +} + +func TestAPIQuotaOrgQuotaQuery(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := prepareQuotaEnv(t, "quota-enforcement") + defer env.Cleanup() + + env.SetupWithSingleQuotaRule(t) + env.AddUnlimitedOrg(t) + env.AddLimitedOrg(t) + + // Look at the quota use of our user, and the unlimited org, for later + // comparison. + var userInfo api.QuotaInfo + req := NewRequest(t, "GET", "/api/v1/user/quota").AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &userInfo) + + var orgInfo api.QuotaInfo + req = NewRequestf(t, "GET", "/api/v1/orgs/%s/quota", env.Orgs.Unlimited.Name). + AddTokenAuth(env.User.Token) + resp = env.User.Session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &orgInfo) + + assert.Positive(t, userInfo.Used.Size.Repos.Public) + assert.EqualValues(t, 0, orgInfo.Used.Size.Repos.Public) + }) +} + +func TestAPIQuotaUserBasics(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := prepareQuotaEnv(t, "quota-enforcement") + defer env.Cleanup() + + env.SetupWithMultipleQuotaRules(t) + + t.Run("quota usage change", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user/quota").AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusOK) + + var q api.QuotaInfo + DecodeJSON(t, resp, &q) + + assert.Positive(t, q.Used.Size.Repos.Public) + assert.Empty(t, q.Groups[0].Name) + assert.Empty(t, q.Groups[0].Rules[0].Name) + + t.Run("admin view", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestf(t, "GET", "/api/v1/admin/users/%s/quota", env.User.User.Name).AddTokenAuth(env.Admin.Token) + resp := env.Admin.Session.MakeRequest(t, req, http.StatusOK) + + var q api.QuotaInfo + DecodeJSON(t, resp, &q) + + assert.Positive(t, q.Used.Size.Repos.Public) + + assert.NotEmpty(t, q.Groups[0].Name) + assert.NotEmpty(t, q.Groups[0].Rules[0].Name) + }) + }) + + t.Run("quota check passing", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user/quota/check?subject=size:repos:all").AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusOK) + + var q bool + DecodeJSON(t, resp, &q) + + assert.True(t, q) + }) + + t.Run("quota check failing after limit change", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer env.SetRuleLimit(t, "repo-size", 0)() + + req := NewRequest(t, "GET", "/api/v1/user/quota/check?subject=size:repos:all").AddTokenAuth(env.User.Token) + resp := env.User.Session.MakeRequest(t, req, http.StatusOK) + + var q bool + DecodeJSON(t, resp, &q) + + assert.False(t, q) + }) + + t.Run("quota enforcement", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer env.SetRuleLimit(t, "repo-size", 0)() + + t.Run("repoCreateFile", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/contents/new-file.txt"), api.CreateFileOptions{ + ContentBase64: base64.StdEncoding.EncodeToString([]byte("hello world")), + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("repoCreateBranch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{ + BranchName: "new-branch", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge) + }) + + t.Run("repoDeleteBranch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Temporarily disable quota checking + defer env.SetRuleLimit(t, "repo-size", -1)() + defer env.SetRuleLimit(t, "all", -1)() + + // Create a branch + req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{ + BranchName: "branch-to-delete", + }).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusCreated) + + // Set the limit back. No need to defer, the first one will set it + // back to the correct value. + env.SetRuleLimit(t, "all", 0) + env.SetRuleLimit(t, "repo-size", 0) + + // Deleting a branch does not incur quota enforcement + req = NewRequest(t, "DELETE", env.APIPathForRepo("/branches/branch-to-delete")).AddTokenAuth(env.User.Token) + env.User.Session.MakeRequest(t, req, http.StatusNoContent) + }) + }) + }) +} diff --git a/tests/integration/api_releases_test.go b/tests/integration/api_releases_test.go new file mode 100644 index 0000000..1bd8a64 --- /dev/null +++ b/tests/integration/api_releases_test.go @@ -0,0 +1,473 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIListReleases(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeReadRepository) + + link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/releases", user2.Name, repo.Name)) + resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) + var apiReleases []*api.Release + DecodeJSON(t, resp, &apiReleases) + if assert.Len(t, apiReleases, 3) { + for _, release := range apiReleases { + switch release.ID { + case 1: + assert.False(t, release.IsDraft) + assert.False(t, release.IsPrerelease) + assert.True(t, strings.HasSuffix(release.UploadURL, "/api/v1/repos/user2/repo1/releases/1/assets"), release.UploadURL) + case 4: + assert.True(t, release.IsDraft) + assert.False(t, release.IsPrerelease) + assert.True(t, strings.HasSuffix(release.UploadURL, "/api/v1/repos/user2/repo1/releases/4/assets"), release.UploadURL) + case 5: + assert.False(t, release.IsDraft) + assert.True(t, release.IsPrerelease) + assert.True(t, strings.HasSuffix(release.UploadURL, "/api/v1/repos/user2/repo1/releases/5/assets"), release.UploadURL) + default: + require.NoError(t, fmt.Errorf("unexpected release: %v", release)) + } + } + } + + // test filter + testFilterByLen := func(auth bool, query url.Values, expectedLength int, msgAndArgs ...string) { + link.RawQuery = query.Encode() + req := NewRequest(t, "GET", link.String()) + if auth { + req.AddTokenAuth(token) + } + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiReleases) + assert.Len(t, apiReleases, expectedLength, msgAndArgs) + } + + testFilterByLen(false, url.Values{"draft": {"true"}}, 0, "anon should not see drafts") + testFilterByLen(true, url.Values{"draft": {"true"}}, 1, "repo owner should see drafts") + testFilterByLen(true, url.Values{"draft": {"false"}}, 2, "exclude drafts") + testFilterByLen(true, url.Values{"draft": {"false"}, "pre-release": {"false"}}, 1, "exclude drafts and pre-releases") + testFilterByLen(true, url.Values{"pre-release": {"true"}}, 1, "only get pre-release") + testFilterByLen(true, url.Values{"draft": {"true"}, "pre-release": {"true"}}, 0, "there is no pre-release draft") +} + +func createNewReleaseUsingAPI(t *testing.T, token string, owner *user_model.User, repo *repo_model.Repository, name, target, title, desc string) *api.Release { + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/releases", owner.Name, repo.Name) + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateReleaseOption{ + TagName: name, + Title: title, + Note: desc, + IsDraft: false, + IsPrerelease: false, + Target: target, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var newRelease api.Release + DecodeJSON(t, resp, &newRelease) + rel := &repo_model.Release{ + ID: newRelease.ID, + TagName: newRelease.TagName, + Title: newRelease.Title, + } + unittest.AssertExistsAndLoadBean(t, rel) + assert.EqualValues(t, newRelease.Note, rel.Note) + + return &newRelease +} + +func TestAPICreateAndUpdateRelease(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, owner.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo) + require.NoError(t, err) + defer gitRepo.Close() + + err = gitRepo.CreateTag("v0.0.1", "master") + require.NoError(t, err) + + target, err := gitRepo.GetTagCommitID("v0.0.1") + require.NoError(t, err) + + newRelease := createNewReleaseUsingAPI(t, token, owner, repo, "v0.0.1", target, "v0.0.1", "test") + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d", owner.Name, repo.Name, newRelease.ID) + req := NewRequest(t, "GET", urlStr). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var release api.Release + DecodeJSON(t, resp, &release) + + assert.Equal(t, newRelease.TagName, release.TagName) + assert.Equal(t, newRelease.Title, release.Title) + assert.Equal(t, newRelease.Note, release.Note) + assert.False(t, newRelease.HideArchiveLinks) + + hideArchiveLinks := true + + req = NewRequestWithJSON(t, "PATCH", urlStr, &api.EditReleaseOption{ + TagName: release.TagName, + Title: release.Title, + Note: "updated", + IsDraft: &release.IsDraft, + IsPrerelease: &release.IsPrerelease, + Target: release.Target, + HideArchiveLinks: &hideArchiveLinks, + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &newRelease) + rel := &repo_model.Release{ + ID: newRelease.ID, + TagName: newRelease.TagName, + Title: newRelease.Title, + } + unittest.AssertExistsAndLoadBean(t, rel) + assert.EqualValues(t, rel.Note, newRelease.Note) + assert.True(t, newRelease.HideArchiveLinks) +} + +func TestAPICreateProtectedTagRelease(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + writer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + session := loginUser(t, writer.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo) + require.NoError(t, err) + defer gitRepo.Close() + + commit, err := gitRepo.GetBranchCommit("master") + require.NoError(t, err) + + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/releases", repo.OwnerName, repo.Name), &api.CreateReleaseOption{ + TagName: "v0.0.1", + Title: "v0.0.1", + IsDraft: false, + IsPrerelease: false, + Target: commit.ID.String(), + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) +} + +func TestAPICreateReleaseToDefaultBranch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, owner.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + createNewReleaseUsingAPI(t, token, owner, repo, "v0.0.1", "", "v0.0.1", "test") +} + +func TestAPICreateReleaseToDefaultBranchOnExistingTag(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, owner.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo) + require.NoError(t, err) + defer gitRepo.Close() + + err = gitRepo.CreateTag("v0.0.1", "master") + require.NoError(t, err) + + createNewReleaseUsingAPI(t, token, owner, repo, "v0.0.1", "", "v0.0.1", "test") +} + +func TestAPICreateReleaseGivenInvalidTarget(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, owner.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/releases", owner.Name, repo.Name) + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateReleaseOption{ + TagName: "i-point-to-an-invalid-target", + Title: "Invalid Target", + Target: "invalid-target", + }).AddTokenAuth(token) + + MakeRequest(t, req, http.StatusNotFound) +} + +func TestAPIGetLatestRelease(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/releases/latest", owner.Name, repo.Name)) + resp := MakeRequest(t, req, http.StatusOK) + + var release *api.Release + DecodeJSON(t, resp, &release) + + assert.Equal(t, "testing-release", release.Title) +} + +func TestAPIGetReleaseByTag(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + tag := "v1.1" + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, tag)) + resp := MakeRequest(t, req, http.StatusOK) + + var release *api.Release + DecodeJSON(t, resp, &release) + + assert.Equal(t, "testing-release", release.Title) + + nonexistingtag := "nonexistingtag" + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, nonexistingtag)) + resp = MakeRequest(t, req, http.StatusNotFound) + + var err *api.APIError + DecodeJSON(t, resp, &err) + assert.NotEmpty(t, err.Message) +} + +func TestAPIDeleteReleaseByTagName(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, owner.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test") + + // delete release + req := NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/%s/releases/tags/release-tag", owner.Name, repo.Name). + AddTokenAuth(token) + _ = MakeRequest(t, req, http.StatusNoContent) + + // make sure release is deleted + req = NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/%s/releases/tags/release-tag", owner.Name, repo.Name). + AddTokenAuth(token) + _ = MakeRequest(t, req, http.StatusNotFound) + + // delete release tag too + req = NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/%s/tags/release-tag", owner.Name, repo.Name). + AddTokenAuth(token) + _ = MakeRequest(t, req, http.StatusNoContent) +} + +func TestAPIUploadAssetRelease(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, owner.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test") + + filename := "image.png" + buff := generateImg() + + assetURL := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets", owner.Name, repo.Name, r.ID) + + t.Run("multipart/form-data", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + body := &bytes.Buffer{} + + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + require.NoError(t, err) + _, err = io.Copy(part, bytes.NewReader(buff.Bytes())) + require.NoError(t, err) + err = writer.Close() + require.NoError(t, err) + + req := NewRequestWithBody(t, http.MethodPost, assetURL, bytes.NewReader(body.Bytes())). + AddTokenAuth(token). + SetHeader("Content-Type", writer.FormDataContentType()) + resp := MakeRequest(t, req, http.StatusCreated) + + var attachment *api.Attachment + DecodeJSON(t, resp, &attachment) + + assert.EqualValues(t, filename, attachment.Name) + assert.EqualValues(t, 104, attachment.Size) + + req = NewRequestWithBody(t, http.MethodPost, assetURL+"?name=test-asset", bytes.NewReader(body.Bytes())). + AddTokenAuth(token). + SetHeader("Content-Type", writer.FormDataContentType()) + resp = MakeRequest(t, req, http.StatusCreated) + + var attachment2 *api.Attachment + DecodeJSON(t, resp, &attachment2) + + assert.EqualValues(t, "test-asset", attachment2.Name) + assert.EqualValues(t, 104, attachment2.Size) + }) + + t.Run("application/octet-stream", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithBody(t, http.MethodPost, assetURL, bytes.NewReader(buff.Bytes())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequestWithBody(t, http.MethodPost, assetURL+"?name=stream.bin", bytes.NewReader(buff.Bytes())). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var attachment *api.Attachment + DecodeJSON(t, resp, &attachment) + + assert.EqualValues(t, "stream.bin", attachment.Name) + assert.EqualValues(t, 104, attachment.Size) + assert.EqualValues(t, "attachment", attachment.Type) + }) +} + +func TestAPIGetReleaseArchiveDownloadCount(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, owner.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + name := "ReleaseDownloadCount" + + createNewReleaseUsingAPI(t, token, owner, repo, name, "", name, "test") + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, name) + + req := NewRequest(t, "GET", urlStr) + resp := MakeRequest(t, req, http.StatusOK) + + var release *api.Release + DecodeJSON(t, resp, &release) + + // Check if everything defaults to 0 + assert.Equal(t, int64(0), release.ArchiveDownloadCount.TarGz) + assert.Equal(t, int64(0), release.ArchiveDownloadCount.Zip) + + // Download the tarball to increase the count + MakeRequest(t, NewRequest(t, "GET", release.TarURL), http.StatusOK) + + // Check if the count has increased + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &release) + + assert.Equal(t, int64(1), release.ArchiveDownloadCount.TarGz) + assert.Equal(t, int64(0), release.ArchiveDownloadCount.Zip) +} + +func TestAPIExternalAssetRelease(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, owner.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test") + + req := NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets?name=test-asset&external_url=https%%3A%%2F%%2Fforgejo.org%%2F", owner.Name, repo.Name, r.ID)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var attachment *api.Attachment + DecodeJSON(t, resp, &attachment) + + assert.EqualValues(t, "test-asset", attachment.Name) + assert.EqualValues(t, 0, attachment.Size) + assert.EqualValues(t, "https://forgejo.org/", attachment.DownloadURL) + assert.EqualValues(t, "external", attachment.Type) +} + +func TestAPIDuplicateAssetRelease(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, owner.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test") + + filename := "image.png" + buff := generateImg() + body := &bytes.Buffer{} + + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + require.NoError(t, err) + _, err = io.Copy(part, &buff) + require.NoError(t, err) + err = writer.Close() + require.NoError(t, err) + + req := NewRequestWithBody(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets?name=test-asset&external_url=https%%3A%%2F%%2Fforgejo.org%%2F", owner.Name, repo.Name, r.ID), body). + AddTokenAuth(token) + req.Header.Add("Content-Type", writer.FormDataContentType()) + MakeRequest(t, req, http.StatusBadRequest) +} + +func TestAPIMissingAssetRelease(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, owner.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test") + + req := NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets?name=test-asset", owner.Name, repo.Name, r.ID)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusBadRequest) +} diff --git a/tests/integration/api_repo_activities_test.go b/tests/integration/api_repo_activities_test.go new file mode 100644 index 0000000..dbdedec --- /dev/null +++ b/tests/integration/api_repo_activities_test.go @@ -0,0 +1,88 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIRepoActivitiyFeeds(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + repo, _, f := tests.CreateDeclarativeRepoWithOptions(t, owner, tests.DeclarativeRepoOptions{}) + defer f() + + feedURL := fmt.Sprintf("/api/v1/repos/%s/activities/feeds", repo.FullName()) + assertAndReturnActivities := func(t *testing.T, length int) []api.Activity { + t.Helper() + + req := NewRequest(t, "GET", feedURL) + resp := MakeRequest(t, req, http.StatusOK) + var activities []api.Activity + DecodeJSON(t, resp, &activities) + + assert.Len(t, activities, length) + + return activities + } + createIssue := func(t *testing.T) { + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/issues?state=all", repo.FullName()) + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{ + Title: "ActivityFeed test", + Body: "Nothing to see here!", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + } + + t.Run("repo creation", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Upon repo creation, there's a single activity. + assertAndReturnActivities(t, 1) + }) + + t.Run("single watcher, single issue", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // After creating an issue, we'll have two activities. + createIssue(t) + assertAndReturnActivities(t, 2) + }) + + t.Run("a new watcher, no new activities", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + watcher := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + watcherSession := loginUser(t, watcher.Name) + watcherToken := getTokenForLoggedInUser(t, watcherSession, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeReadUser) + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/subscription", repo.FullName())). + AddTokenAuth(watcherToken) + MakeRequest(t, req, http.StatusOK) + + assertAndReturnActivities(t, 2) + }) + + t.Run("multiple watchers, new issue", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // After creating a second issue, we'll have three activities, even + // though we have multiple watchers. + createIssue(t) + assertAndReturnActivities(t, 3) + }) +} diff --git a/tests/integration/api_repo_archive_test.go b/tests/integration/api_repo_archive_test.go new file mode 100644 index 0000000..a16136a --- /dev/null +++ b/tests/integration/api_repo_archive_test.go @@ -0,0 +1,65 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIDownloadArchive(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master.zip", user2.Name, repo.Name)) + resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) + bs, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Len(t, bs, 320) + assert.EqualValues(t, "application/zip", resp.Header().Get("Content-Type")) + + link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master.tar.gz", user2.Name, repo.Name)) + resp = MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) + bs, err = io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Len(t, bs, 266) + assert.EqualValues(t, "application/gzip", resp.Header().Get("Content-Type")) + + // Must return a link to a commit ID as the "immutable" archive link + linkHeaderRe := regexp.MustCompile(`<(?P<url>https?://.*/api/v1/repos/user2/repo1/archive/[a-f0-9]+\.tar\.gz.*)>; rel="immutable"`) + m := linkHeaderRe.FindStringSubmatch(resp.Header().Get("Link")) + assert.NotEmpty(t, m[1]) + resp = MakeRequest(t, NewRequest(t, "GET", m[1]).AddTokenAuth(token), http.StatusOK) + bs2, err := io.ReadAll(resp.Body) + require.NoError(t, err) + // The locked URL should give the same bytes as the non-locked one + assert.EqualValues(t, bs, bs2) + + link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master.bundle", user2.Name, repo.Name)) + resp = MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) + bs, err = io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Len(t, bs, 382) + assert.EqualValues(t, "application/octet-stream", resp.Header().Get("Content-Type")) + + link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master", user2.Name, repo.Name)) + MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusBadRequest) +} diff --git a/tests/integration/api_repo_avatar_test.go b/tests/integration/api_repo_avatar_test.go new file mode 100644 index 0000000..8ee256e --- /dev/null +++ b/tests/integration/api_repo_avatar_test.go @@ -0,0 +1,81 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "fmt" + "net/http" + "os" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIUpdateRepoAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeWriteRepository) + + // Test what happens if you use a valid image + avatar, err := os.ReadFile("tests/integration/avatar.png") + require.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open avatar.png") + } + + opts := api.UpdateRepoAvatarOption{ + Image: base64.StdEncoding.EncodeToString(avatar), + } + + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/avatar", repo.OwnerName, repo.Name), &opts). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // Test what happens if you don't have a valid Base64 string + opts = api.UpdateRepoAvatarOption{ + Image: "Invalid", + } + + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/avatar", repo.OwnerName, repo.Name), &opts). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusBadRequest) + + // Test what happens if you use a file that is not an image + text, err := os.ReadFile("tests/integration/README.md") + require.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open README.md") + } + + opts = api.UpdateRepoAvatarOption{ + Image: base64.StdEncoding.EncodeToString(text), + } + + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/avatar", repo.OwnerName, repo.Name), &opts). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusInternalServerError) +} + +func TestAPIDeleteRepoAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/avatar", repo.OwnerName, repo.Name)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) +} diff --git a/tests/integration/api_repo_branch_test.go b/tests/integration/api_repo_branch_test.go new file mode 100644 index 0000000..8315902 --- /dev/null +++ b/tests/integration/api_repo_branch_test.go @@ -0,0 +1,131 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIRepoBranchesPlain(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + session := loginUser(t, user1.LowerName) + + // public only token should be forbidden + publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeWriteRepository) + link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches", repo3.Name)) // a plain repo + MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden) + + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) + bs, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var branches []*api.Branch + require.NoError(t, json.Unmarshal(bs, &branches)) + assert.Len(t, branches, 2) + assert.EqualValues(t, "test_branch", branches[0].Name) + assert.EqualValues(t, "master", branches[1].Name) + + link2, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches/test_branch", repo3.Name)) + MakeRequest(t, NewRequest(t, "GET", link2.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden) + + resp = MakeRequest(t, NewRequest(t, "GET", link2.String()).AddTokenAuth(token), http.StatusOK) + bs, err = io.ReadAll(resp.Body) + require.NoError(t, err) + var branch api.Branch + require.NoError(t, json.Unmarshal(bs, &branch)) + assert.EqualValues(t, "test_branch", branch.Name) + + MakeRequest(t, NewRequest(t, "POST", link.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden) + + req := NewRequest(t, "POST", link.String()).AddTokenAuth(token) + req.Header.Add("Content-Type", "application/json") + req.Body = io.NopCloser(bytes.NewBufferString(`{"new_branch_name":"test_branch2", "old_branch_name": "test_branch", "old_ref_name":"refs/heads/test_branch"}`)) + resp = MakeRequest(t, req, http.StatusCreated) + bs, err = io.ReadAll(resp.Body) + require.NoError(t, err) + var branch2 api.Branch + require.NoError(t, json.Unmarshal(bs, &branch2)) + assert.EqualValues(t, "test_branch2", branch2.Name) + assert.EqualValues(t, branch.Commit.ID, branch2.Commit.ID) + + resp = MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) + bs, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + branches = []*api.Branch{} + require.NoError(t, json.Unmarshal(bs, &branches)) + assert.Len(t, branches, 3) + assert.EqualValues(t, "test_branch", branches[0].Name) + assert.EqualValues(t, "test_branch2", branches[1].Name) + assert.EqualValues(t, "master", branches[2].Name) + + link3, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches/test_branch2", repo3.Name)) + MakeRequest(t, NewRequest(t, "DELETE", link3.String()), http.StatusNotFound) + MakeRequest(t, NewRequest(t, "DELETE", link3.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden) + + MakeRequest(t, NewRequest(t, "DELETE", link3.String()).AddTokenAuth(token), http.StatusNoContent) + require.NoError(t, err) + }) +} + +func TestAPIRepoBranchesMirror(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo5 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5}) + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + session := loginUser(t, user1.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches", repo5.Name)) // a mirror repo + resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK) + bs, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var branches []*api.Branch + require.NoError(t, json.Unmarshal(bs, &branches)) + assert.Len(t, branches, 2) + assert.EqualValues(t, "test_branch", branches[0].Name) + assert.EqualValues(t, "master", branches[1].Name) + + link2, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches/test_branch", repo5.Name)) + resp = MakeRequest(t, NewRequest(t, "GET", link2.String()).AddTokenAuth(token), http.StatusOK) + bs, err = io.ReadAll(resp.Body) + require.NoError(t, err) + var branch api.Branch + require.NoError(t, json.Unmarshal(bs, &branch)) + assert.EqualValues(t, "test_branch", branch.Name) + + req := NewRequest(t, "POST", link.String()).AddTokenAuth(token) + req.Header.Add("Content-Type", "application/json") + req.Body = io.NopCloser(bytes.NewBufferString(`{"new_branch_name":"test_branch2", "old_branch_name": "test_branch", "old_ref_name":"refs/heads/test_branch"}`)) + resp = MakeRequest(t, req, http.StatusForbidden) + bs, err = io.ReadAll(resp.Body) + require.NoError(t, err) + assert.EqualValues(t, "{\"message\":\"Git Repository is a mirror.\",\"url\":\""+setting.AppURL+"api/swagger\"}\n", string(bs)) + + resp = MakeRequest(t, NewRequest(t, "DELETE", link2.String()).AddTokenAuth(token), http.StatusForbidden) + bs, err = io.ReadAll(resp.Body) + require.NoError(t, err) + assert.EqualValues(t, "{\"message\":\"Git Repository is a mirror.\",\"url\":\""+setting.AppURL+"api/swagger\"}\n", string(bs)) +} diff --git a/tests/integration/api_repo_collaborator_test.go b/tests/integration/api_repo_collaborator_test.go new file mode 100644 index 0000000..59cf85f --- /dev/null +++ b/tests/integration/api_repo_collaborator_test.go @@ -0,0 +1,138 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" +) + +func TestAPIRepoCollaboratorPermission(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + repo2Owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo2.OwnerID}) + + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + user10 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10}) + user11 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 11}) + + testCtx := NewAPITestContext(t, repo2Owner.Name, repo2.Name, auth_model.AccessTokenScopeWriteRepository) + + t.Run("RepoOwnerShouldBeOwner", func(t *testing.T) { + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission", repo2Owner.Name, repo2.Name, repo2Owner.Name). + AddTokenAuth(testCtx.Token) + resp := MakeRequest(t, req, http.StatusOK) + + var repoPermission api.RepoCollaboratorPermission + DecodeJSON(t, resp, &repoPermission) + + assert.Equal(t, "owner", repoPermission.Permission) + }) + + t.Run("CollaboratorWithReadAccess", func(t *testing.T) { + t.Run("AddUserAsCollaboratorWithReadAccess", doAPIAddCollaborator(testCtx, user4.Name, perm.AccessModeRead)) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission", repo2Owner.Name, repo2.Name, user4.Name). + AddTokenAuth(testCtx.Token) + resp := MakeRequest(t, req, http.StatusOK) + + var repoPermission api.RepoCollaboratorPermission + DecodeJSON(t, resp, &repoPermission) + + assert.Equal(t, "read", repoPermission.Permission) + }) + + t.Run("CollaboratorWithWriteAccess", func(t *testing.T) { + t.Run("AddUserAsCollaboratorWithWriteAccess", doAPIAddCollaborator(testCtx, user4.Name, perm.AccessModeWrite)) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission", repo2Owner.Name, repo2.Name, user4.Name). + AddTokenAuth(testCtx.Token) + resp := MakeRequest(t, req, http.StatusOK) + + var repoPermission api.RepoCollaboratorPermission + DecodeJSON(t, resp, &repoPermission) + + assert.Equal(t, "write", repoPermission.Permission) + }) + + t.Run("CollaboratorWithAdminAccess", func(t *testing.T) { + t.Run("AddUserAsCollaboratorWithAdminAccess", doAPIAddCollaborator(testCtx, user4.Name, perm.AccessModeAdmin)) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission", repo2Owner.Name, repo2.Name, user4.Name). + AddTokenAuth(testCtx.Token) + resp := MakeRequest(t, req, http.StatusOK) + + var repoPermission api.RepoCollaboratorPermission + DecodeJSON(t, resp, &repoPermission) + + assert.Equal(t, "admin", repoPermission.Permission) + }) + + t.Run("CollaboratorNotFound", func(t *testing.T) { + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission", repo2Owner.Name, repo2.Name, "non-existent-user"). + AddTokenAuth(testCtx.Token) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("CollaboratorCanQueryItsPermissions", func(t *testing.T) { + t.Run("AddUserAsCollaboratorWithReadAccess", doAPIAddCollaborator(testCtx, user5.Name, perm.AccessModeRead)) + + _session := loginUser(t, user5.Name) + _testCtx := NewAPITestContext(t, user5.Name, repo2.Name, auth_model.AccessTokenScopeReadRepository) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission", repo2Owner.Name, repo2.Name, user5.Name). + AddTokenAuth(_testCtx.Token) + resp := _session.MakeRequest(t, req, http.StatusOK) + + var repoPermission api.RepoCollaboratorPermission + DecodeJSON(t, resp, &repoPermission) + + assert.Equal(t, "read", repoPermission.Permission) + }) + + t.Run("CollaboratorCanQueryItsPermissions", func(t *testing.T) { + t.Run("AddUserAsCollaboratorWithReadAccess", doAPIAddCollaborator(testCtx, user5.Name, perm.AccessModeRead)) + + _session := loginUser(t, user5.Name) + _testCtx := NewAPITestContext(t, user5.Name, repo2.Name, auth_model.AccessTokenScopeReadRepository) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission", repo2Owner.Name, repo2.Name, user5.Name). + AddTokenAuth(_testCtx.Token) + resp := _session.MakeRequest(t, req, http.StatusOK) + + var repoPermission api.RepoCollaboratorPermission + DecodeJSON(t, resp, &repoPermission) + + assert.Equal(t, "read", repoPermission.Permission) + }) + + t.Run("RepoAdminCanQueryACollaboratorsPermissions", func(t *testing.T) { + t.Run("AddUserAsCollaboratorWithAdminAccess", doAPIAddCollaborator(testCtx, user10.Name, perm.AccessModeAdmin)) + t.Run("AddUserAsCollaboratorWithReadAccess", doAPIAddCollaborator(testCtx, user11.Name, perm.AccessModeRead)) + + _session := loginUser(t, user10.Name) + _testCtx := NewAPITestContext(t, user10.Name, repo2.Name, auth_model.AccessTokenScopeReadRepository) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission", repo2Owner.Name, repo2.Name, user11.Name). + AddTokenAuth(_testCtx.Token) + resp := _session.MakeRequest(t, req, http.StatusOK) + + var repoPermission api.RepoCollaboratorPermission + DecodeJSON(t, resp, &repoPermission) + + assert.Equal(t, "read", repoPermission.Permission) + }) + }) +} diff --git a/tests/integration/api_repo_compare_test.go b/tests/integration/api_repo_compare_test.go new file mode 100644 index 0000000..765d0ce --- /dev/null +++ b/tests/integration/api_repo_compare_test.go @@ -0,0 +1,57 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPICompareBranches(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + // Login as User2. + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + repoName := "repo20" + + req := NewRequestf(t, "GET", "/api/v1/repos/user2/%s/compare/add-csv...remove-files-b", repoName). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var apiResp *api.Compare + DecodeJSON(t, resp, &apiResp) + + assert.Equal(t, 2, apiResp.TotalCommits) + assert.Len(t, apiResp.Commits, 2) +} + +func TestAPICompareCommits(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + // Login as User2. + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo20/compare/c8e31bc...8babce9"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var apiResp *api.Compare + DecodeJSON(t, resp, &apiResp) + + assert.Equal(t, 2, apiResp.TotalCommits) + assert.Len(t, apiResp.Commits, 2) +} diff --git a/tests/integration/api_repo_edit_test.go b/tests/integration/api_repo_edit_test.go new file mode 100644 index 0000000..7de8910 --- /dev/null +++ b/tests/integration/api_repo_edit_test.go @@ -0,0 +1,368 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" +) + +// getRepoEditOptionFromRepo gets the options for an existing repo exactly as is +func getRepoEditOptionFromRepo(repo *repo_model.Repository) *api.EditRepoOption { + name := repo.Name + description := repo.Description + website := repo.Website + private := repo.IsPrivate + hasIssues := false + var internalTracker *api.InternalTracker + var externalTracker *api.ExternalTracker + if unit, err := repo.GetUnit(db.DefaultContext, unit_model.TypeIssues); err == nil { + config := unit.IssuesConfig() + hasIssues = true + internalTracker = &api.InternalTracker{ + EnableTimeTracker: config.EnableTimetracker, + AllowOnlyContributorsToTrackTime: config.AllowOnlyContributorsToTrackTime, + EnableIssueDependencies: config.EnableDependencies, + } + } else if unit, err := repo.GetUnit(db.DefaultContext, unit_model.TypeExternalTracker); err == nil { + config := unit.ExternalTrackerConfig() + hasIssues = true + externalTracker = &api.ExternalTracker{ + ExternalTrackerURL: config.ExternalTrackerURL, + ExternalTrackerFormat: config.ExternalTrackerFormat, + ExternalTrackerStyle: config.ExternalTrackerStyle, + ExternalTrackerRegexpPattern: config.ExternalTrackerRegexpPattern, + } + } + hasWiki := false + var externalWiki *api.ExternalWiki + if _, err := repo.GetUnit(db.DefaultContext, unit_model.TypeWiki); err == nil { + hasWiki = true + } else if unit, err := repo.GetUnit(db.DefaultContext, unit_model.TypeExternalWiki); err == nil { + hasWiki = true + config := unit.ExternalWikiConfig() + externalWiki = &api.ExternalWiki{ + ExternalWikiURL: config.ExternalWikiURL, + } + } + defaultBranch := repo.DefaultBranch + hasPullRequests := false + ignoreWhitespaceConflicts := false + allowMerge := false + allowRebase := false + allowRebaseMerge := false + allowSquash := false + allowFastForwardOnly := false + if unit, err := repo.GetUnit(db.DefaultContext, unit_model.TypePullRequests); err == nil { + config := unit.PullRequestsConfig() + hasPullRequests = true + ignoreWhitespaceConflicts = config.IgnoreWhitespaceConflicts + allowMerge = config.AllowMerge + allowRebase = config.AllowRebase + allowRebaseMerge = config.AllowRebaseMerge + allowSquash = config.AllowSquash + allowFastForwardOnly = config.AllowFastForwardOnly + } + archived := repo.IsArchived + return &api.EditRepoOption{ + Name: &name, + Description: &description, + Website: &website, + Private: &private, + HasIssues: &hasIssues, + ExternalTracker: externalTracker, + InternalTracker: internalTracker, + HasWiki: &hasWiki, + ExternalWiki: externalWiki, + DefaultBranch: &defaultBranch, + HasPullRequests: &hasPullRequests, + IgnoreWhitespaceConflicts: &ignoreWhitespaceConflicts, + AllowMerge: &allowMerge, + AllowRebase: &allowRebase, + AllowRebaseMerge: &allowRebaseMerge, + AllowSquash: &allowSquash, + AllowFastForwardOnly: &allowFastForwardOnly, + Archived: &archived, + } +} + +// getNewRepoEditOption Gets the options to change everything about an existing repo by adding to strings or changing +// the boolean +func getNewRepoEditOption(opts *api.EditRepoOption) *api.EditRepoOption { + // Gives a new property to everything + name := *opts.Name + "renamed" + description := "new description" + website := "http://wwww.newwebsite.com" + private := !*opts.Private + hasIssues := !*opts.HasIssues + hasWiki := !*opts.HasWiki + defaultBranch := "master" + hasPullRequests := !*opts.HasPullRequests + ignoreWhitespaceConflicts := !*opts.IgnoreWhitespaceConflicts + allowMerge := !*opts.AllowMerge + allowRebase := !*opts.AllowRebase + allowRebaseMerge := !*opts.AllowRebaseMerge + allowSquash := !*opts.AllowSquash + archived := !*opts.Archived + + return &api.EditRepoOption{ + Name: &name, + Description: &description, + Website: &website, + Private: &private, + DefaultBranch: &defaultBranch, + HasIssues: &hasIssues, + HasWiki: &hasWiki, + HasPullRequests: &hasPullRequests, + IgnoreWhitespaceConflicts: &ignoreWhitespaceConflicts, + AllowMerge: &allowMerge, + AllowRebase: &allowRebase, + AllowRebaseMerge: &allowRebaseMerge, + AllowSquash: &allowSquash, + Archived: &archived, + } +} + +func TestAPIRepoEdit(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + bFalse, bTrue := false, true + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16 + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo + repo15 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 15}) // empty repo + repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo + + // Get user2's token + session := loginUser(t, user2.Name) + token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + // Get user4's token + session = loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // Test editing a repo1 which user2 owns, changing name and many properties + origRepoEditOption := getRepoEditOptionFromRepo(repo1) + repoEditOption := getNewRepoEditOption(origRepoEditOption) + req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo1.Name), &repoEditOption). + AddTokenAuth(token2) + resp := MakeRequest(t, req, http.StatusOK) + var repo api.Repository + DecodeJSON(t, resp, &repo) + assert.NotNil(t, repo) + // check response + assert.Equal(t, *repoEditOption.Name, repo.Name) + assert.Equal(t, *repoEditOption.Description, repo.Description) + assert.Equal(t, *repoEditOption.Website, repo.Website) + assert.Equal(t, *repoEditOption.Archived, repo.Archived) + // check repo1 from database + repo1edited := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo1editedOption := getRepoEditOptionFromRepo(repo1edited) + assert.Equal(t, *repoEditOption.Name, *repo1editedOption.Name) + assert.Equal(t, *repoEditOption.Description, *repo1editedOption.Description) + assert.Equal(t, *repoEditOption.Website, *repo1editedOption.Website) + assert.Equal(t, *repoEditOption.Archived, *repo1editedOption.Archived) + assert.Equal(t, *repoEditOption.Private, *repo1editedOption.Private) + assert.Equal(t, *repoEditOption.HasWiki, *repo1editedOption.HasWiki) + + // Test editing repo1 to use internal issue and wiki (default) + *repoEditOption.HasIssues = true + repoEditOption.ExternalTracker = nil + repoEditOption.InternalTracker = &api.InternalTracker{ + EnableTimeTracker: false, + AllowOnlyContributorsToTrackTime: false, + EnableIssueDependencies: false, + } + *repoEditOption.HasWiki = true + repoEditOption.ExternalWiki = nil + url := fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, *repoEditOption.Name) + req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption). + AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repo) + assert.NotNil(t, repo) + // check repo1 was written to database + repo1edited = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo1editedOption = getRepoEditOptionFromRepo(repo1edited) + assert.True(t, *repo1editedOption.HasIssues) + assert.Nil(t, repo1editedOption.ExternalTracker) + assert.Equal(t, *repo1editedOption.InternalTracker, *repoEditOption.InternalTracker) + assert.True(t, *repo1editedOption.HasWiki) + assert.Nil(t, repo1editedOption.ExternalWiki) + + // Test editing repo1 to use external issue and wiki + repoEditOption.ExternalTracker = &api.ExternalTracker{ + ExternalTrackerURL: "http://www.somewebsite.com", + ExternalTrackerFormat: "http://www.somewebsite.com/{user}/{repo}?issue={index}", + ExternalTrackerStyle: "alphanumeric", + } + repoEditOption.ExternalWiki = &api.ExternalWiki{ + ExternalWikiURL: "http://www.somewebsite.com", + } + req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption). + AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repo) + assert.NotNil(t, repo) + // check repo1 was written to database + repo1edited = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo1editedOption = getRepoEditOptionFromRepo(repo1edited) + assert.True(t, *repo1editedOption.HasIssues) + assert.Equal(t, *repo1editedOption.ExternalTracker, *repoEditOption.ExternalTracker) + assert.True(t, *repo1editedOption.HasWiki) + assert.Equal(t, *repo1editedOption.ExternalWiki, *repoEditOption.ExternalWiki) + + repoEditOption.ExternalTracker.ExternalTrackerStyle = "regexp" + repoEditOption.ExternalTracker.ExternalTrackerRegexpPattern = `(\d+)` + req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption). + AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repo) + assert.NotNil(t, repo) + repo1edited = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo1editedOption = getRepoEditOptionFromRepo(repo1edited) + assert.True(t, *repo1editedOption.HasIssues) + assert.Equal(t, *repo1editedOption.ExternalTracker, *repoEditOption.ExternalTracker) + + // Do some tests with invalid URL for external tracker and wiki + repoEditOption.ExternalTracker.ExternalTrackerURL = "htp://www.somewebsite.com" + req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusUnprocessableEntity) + repoEditOption.ExternalTracker.ExternalTrackerURL = "http://www.somewebsite.com" + repoEditOption.ExternalTracker.ExternalTrackerFormat = "http://www.somewebsite.com/{user/{repo}?issue={index}" + req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusUnprocessableEntity) + repoEditOption.ExternalTracker.ExternalTrackerFormat = "http://www.somewebsite.com/{user}/{repo}?issue={index}" + repoEditOption.ExternalWiki.ExternalWikiURL = "htp://www.somewebsite.com" + req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + // Test small repo change through API with issue and wiki option not set; They shall not be touched. + *repoEditOption.Description = "small change" + repoEditOption.HasIssues = nil + repoEditOption.ExternalTracker = nil + repoEditOption.HasWiki = nil + repoEditOption.ExternalWiki = nil + req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption). + AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repo) + assert.NotNil(t, repo) + // check repo1 was written to database + repo1edited = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo1editedOption = getRepoEditOptionFromRepo(repo1edited) + assert.Equal(t, *repo1editedOption.Description, *repoEditOption.Description) + assert.True(t, *repo1editedOption.HasIssues) + assert.NotNil(t, *repo1editedOption.ExternalTracker) + assert.True(t, *repo1editedOption.HasWiki) + assert.NotNil(t, *repo1editedOption.ExternalWiki) + + // reset repo in db + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, *repoEditOption.Name), &origRepoEditOption). + AddTokenAuth(token2) + _ = MakeRequest(t, req, http.StatusOK) + + // Test editing a non-existing repo + name := "repodoesnotexist" + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, name), &api.EditRepoOption{Name: &name}). + AddTokenAuth(token2) + _ = MakeRequest(t, req, http.StatusNotFound) + + // Test editing repo16 by user4 who does not have write access + origRepoEditOption = getRepoEditOptionFromRepo(repo16) + repoEditOption = getNewRepoEditOption(origRepoEditOption) + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo16.Name), &repoEditOption). + AddTokenAuth(token4) + MakeRequest(t, req, http.StatusNotFound) + + // Tests a repo with no token given so will fail + origRepoEditOption = getRepoEditOptionFromRepo(repo16) + repoEditOption = getNewRepoEditOption(origRepoEditOption) + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo16.Name), &repoEditOption) + _ = MakeRequest(t, req, http.StatusNotFound) + + // Test using access token for a private repo that the user of the token owns + origRepoEditOption = getRepoEditOptionFromRepo(repo16) + repoEditOption = getNewRepoEditOption(origRepoEditOption) + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo16.Name), &repoEditOption). + AddTokenAuth(token2) + _ = MakeRequest(t, req, http.StatusOK) + // reset repo in db + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, *repoEditOption.Name), &origRepoEditOption). + AddTokenAuth(token2) + _ = MakeRequest(t, req, http.StatusOK) + + // Test making a repo public that is private + repo16 = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) + assert.True(t, repo16.IsPrivate) + repoEditOption = &api.EditRepoOption{ + Private: &bFalse, + } + url = fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo16.Name) + req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption). + AddTokenAuth(token2) + _ = MakeRequest(t, req, http.StatusOK) + repo16 = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) + assert.False(t, repo16.IsPrivate) + // Make it private again + repoEditOption.Private = &bTrue + req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption). + AddTokenAuth(token2) + _ = MakeRequest(t, req, http.StatusOK) + + // Test to change empty repo + assert.False(t, repo15.IsArchived) + url = fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo15.Name) + req = NewRequestWithJSON(t, "PATCH", url, &api.EditRepoOption{ + Archived: &bTrue, + }).AddTokenAuth(token2) + _ = MakeRequest(t, req, http.StatusOK) + repo15 = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 15}) + assert.True(t, repo15.IsArchived) + req = NewRequestWithJSON(t, "PATCH", url, &api.EditRepoOption{ + Archived: &bFalse, + }).AddTokenAuth(token2) + _ = MakeRequest(t, req, http.StatusOK) + + // Test using org repo "org3/repo3" where user2 is a collaborator + origRepoEditOption = getRepoEditOptionFromRepo(repo3) + repoEditOption = getNewRepoEditOption(origRepoEditOption) + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", org3.Name, repo3.Name), &repoEditOption). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusOK) + // reset repo in db + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", org3.Name, *repoEditOption.Name), &origRepoEditOption). + AddTokenAuth(token2) + _ = MakeRequest(t, req, http.StatusOK) + + // Test using org repo "org3/repo3" with no user token + origRepoEditOption = getRepoEditOptionFromRepo(repo3) + repoEditOption = getNewRepoEditOption(origRepoEditOption) + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", org3.Name, repo3.Name), &repoEditOption) + MakeRequest(t, req, http.StatusNotFound) + + // Test using repo "user2/repo1" where user4 is a NOT collaborator + origRepoEditOption = getRepoEditOptionFromRepo(repo1) + repoEditOption = getNewRepoEditOption(origRepoEditOption) + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo1.Name), &repoEditOption). + AddTokenAuth(token4) + MakeRequest(t, req, http.StatusForbidden) + }) +} diff --git a/tests/integration/api_repo_file_create_test.go b/tests/integration/api_repo_file_create_test.go new file mode 100644 index 0000000..c7c30db --- /dev/null +++ b/tests/integration/api_repo_file_create_test.go @@ -0,0 +1,312 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + stdCtx "context" + "encoding/base64" + "fmt" + "net/http" + "net/url" + "path/filepath" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" + + "github.com/stretchr/testify/assert" +) + +func getCreateFileOptions() api.CreateFileOptions { + content := "This is new text" + contentEncoded := base64.StdEncoding.EncodeToString([]byte(content)) + return api.CreateFileOptions{ + FileOptions: api.FileOptions{ + BranchName: "master", + NewBranchName: "master", + Message: "Making this new file new/file.txt", + Author: api.Identity{ + Name: "Anne Doe", + Email: "annedoe@example.com", + }, + Committer: api.Identity{ + Name: "John Doe", + Email: "johndoe@example.com", + }, + Dates: api.CommitDateOptions{ + Author: time.Unix(946684810, 0), + Committer: time.Unix(978307190, 0), + }, + }, + ContentBase64: contentEncoded, + } +} + +func getExpectedFileResponseForCreate(repoFullName, commitID, treePath, latestCommitSHA string) *api.FileResponse { + sha := "a635aa942442ddfdba07468cf9661c08fbdf0ebf" + if len(latestCommitSHA) > len(sha) { + // repository is in SHA256 format + sha = "3edd190f61237b7a0a5c49aa47fb58b2ec14d53a2afc90803bc713fab5d5aec0" + } + encoding := "base64" + content := "VGhpcyBpcyBuZXcgdGV4dA==" + selfURL := setting.AppURL + "api/v1/repos/" + repoFullName + "/contents/" + treePath + "?ref=master" + htmlURL := setting.AppURL + repoFullName + "/src/branch/master/" + treePath + gitURL := setting.AppURL + "api/v1/repos/" + repoFullName + "/git/blobs/" + sha + downloadURL := setting.AppURL + repoFullName + "/raw/branch/master/" + treePath + return &api.FileResponse{ + Content: &api.ContentsResponse{ + Name: filepath.Base(treePath), + Path: treePath, + SHA: sha, + LastCommitSHA: latestCommitSHA, + Size: 16, + Type: "file", + Encoding: &encoding, + Content: &content, + URL: &selfURL, + HTMLURL: &htmlURL, + GitURL: &gitURL, + DownloadURL: &downloadURL, + Links: &api.FileLinksResponse{ + Self: &selfURL, + GitURL: &gitURL, + HTMLURL: &htmlURL, + }, + }, + Commit: &api.FileCommitResponse{ + CommitMeta: api.CommitMeta{ + URL: setting.AppURL + "api/v1/repos/" + repoFullName + "/git/commits/" + commitID, + SHA: commitID, + }, + HTMLURL: setting.AppURL + repoFullName + "/commit/" + commitID, + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "Anne Doe", + Email: "annedoe@example.com", + }, + Date: "2000-01-01T00:00:10Z", + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "John Doe", + Email: "johndoe@example.com", + }, + Date: "2000-12-31T23:59:50Z", + }, + Message: "Updates README.md\n", + }, + Verification: &api.PayloadCommitVerification{ + Verified: false, + Reason: "gpg.error.not_signed_commit", + Signature: "", + Payload: "", + }, + } +} + +func BenchmarkAPICreateFileSmall(b *testing.B) { + onGiteaRun(b, func(b *testing.B, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(b, &user_model.User{ID: 2}) // owner of the repo1 & repo16 + repo1 := unittest.AssertExistsAndLoadBean(b, &repo_model.Repository{ID: 1}) // public repo + + b.ResetTimer() + for n := 0; n < b.N; n++ { + treePath := fmt.Sprintf("update/file%d.txt", n) + _, _ = createFileInBranch(user2, repo1, treePath, repo1.DefaultBranch, treePath) + } + }) +} + +func BenchmarkAPICreateFileMedium(b *testing.B) { + data := make([]byte, 10*1024*1024) + + onGiteaRun(b, func(b *testing.B, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(b, &user_model.User{ID: 2}) // owner of the repo1 & repo16 + repo1 := unittest.AssertExistsAndLoadBean(b, &repo_model.Repository{ID: 1}) // public repo + + b.ResetTimer() + for n := 0; n < b.N; n++ { + treePath := fmt.Sprintf("update/file%d.txt", n) + copy(data, treePath) + _, _ = createFileInBranch(user2, repo1, treePath, repo1.DefaultBranch, treePath) + } + }) +} + +func TestAPICreateFile(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16 + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo + repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo + fileID := 0 + + // Get user2's token + session := loginUser(t, user2.Name) + token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + // Get user4's token + session = loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + // Test creating a file in repo1 which user2 owns, try both with branch and empty branch + for _, branch := range [...]string{ + "master", // Branch + "", // Empty branch + } { + createFileOptions := getCreateFileOptions() + createFileOptions.BranchName = branch + fileID++ + treePath := fmt.Sprintf("new/file%d.txt", fileID) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &createFileOptions). + AddTokenAuth(token2) + resp := MakeRequest(t, req, http.StatusCreated) + gitRepo, _ := gitrepo.OpenRepository(stdCtx.Background(), repo1) + commitID, _ := gitRepo.GetBranchCommitID(createFileOptions.NewBranchName) + latestCommit, _ := gitRepo.GetCommitByPath(treePath) + expectedFileResponse := getExpectedFileResponseForCreate("user2/repo1", commitID, treePath, latestCommit.ID.String()) + var fileResponse api.FileResponse + DecodeJSON(t, resp, &fileResponse) + assert.EqualValues(t, expectedFileResponse.Content, fileResponse.Content) + assert.EqualValues(t, expectedFileResponse.Commit.SHA, fileResponse.Commit.SHA) + assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, fileResponse.Commit.HTMLURL) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, fileResponse.Commit.Author.Email) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, fileResponse.Commit.Author.Name) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Date, fileResponse.Commit.Author.Date) + assert.EqualValues(t, expectedFileResponse.Commit.Committer.Email, fileResponse.Commit.Committer.Email) + assert.EqualValues(t, expectedFileResponse.Commit.Committer.Name, fileResponse.Commit.Committer.Name) + assert.EqualValues(t, expectedFileResponse.Commit.Committer.Date, fileResponse.Commit.Committer.Date) + gitRepo.Close() + } + + // Test creating a file in a new branch + createFileOptions := getCreateFileOptions() + createFileOptions.BranchName = repo1.DefaultBranch + createFileOptions.NewBranchName = "new_branch" + fileID++ + treePath := fmt.Sprintf("new/file%d.txt", fileID) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &createFileOptions). + AddTokenAuth(token2) + resp := MakeRequest(t, req, http.StatusCreated) + var fileResponse api.FileResponse + DecodeJSON(t, resp, &fileResponse) + expectedSHA := "a635aa942442ddfdba07468cf9661c08fbdf0ebf" + expectedHTMLURL := fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/new_branch/new/file%d.txt", fileID) + expectedDownloadURL := fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/new_branch/new/file%d.txt", fileID) + assert.EqualValues(t, expectedSHA, fileResponse.Content.SHA) + assert.EqualValues(t, expectedHTMLURL, *fileResponse.Content.HTMLURL) + assert.EqualValues(t, expectedDownloadURL, *fileResponse.Content.DownloadURL) + assert.EqualValues(t, createFileOptions.Message+"\n", fileResponse.Commit.Message) + + // Test creating a file without a message + createFileOptions = getCreateFileOptions() + createFileOptions.Message = "" + fileID++ + treePath = fmt.Sprintf("new/file%d.txt", fileID) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &createFileOptions). + AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, &fileResponse) + expectedMessage := "Add " + treePath + "\n" + assert.EqualValues(t, expectedMessage, fileResponse.Commit.Message) + + // Test trying to create a file that already exists, should fail + createFileOptions = getCreateFileOptions() + treePath = "README.md" + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &createFileOptions). + AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusUnprocessableEntity) + expectedAPIError := context.APIError{ + Message: "repository file already exists [path: " + treePath + "]", + URL: setting.API.SwaggerURL, + } + var apiError context.APIError + DecodeJSON(t, resp, &apiError) + assert.Equal(t, expectedAPIError, apiError) + + // Test creating a file in repo1 by user4 who does not have write access + createFileOptions = getCreateFileOptions() + fileID++ + treePath = fmt.Sprintf("new/file%d.txt", fileID) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &createFileOptions). + AddTokenAuth(token4) + MakeRequest(t, req, http.StatusNotFound) + + // Tests a repo with no token given so will fail + createFileOptions = getCreateFileOptions() + fileID++ + treePath = fmt.Sprintf("new/file%d.txt", fileID) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &createFileOptions) + MakeRequest(t, req, http.StatusNotFound) + + // Test using access token for a private repo that the user of the token owns + createFileOptions = getCreateFileOptions() + fileID++ + treePath = fmt.Sprintf("new/file%d.txt", fileID) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &createFileOptions). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusCreated) + + // Test using org repo "org3/repo3" where user2 is a collaborator + createFileOptions = getCreateFileOptions() + fileID++ + treePath = fmt.Sprintf("new/file%d.txt", fileID) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath), &createFileOptions). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusCreated) + + // Test using org repo "org3/repo3" with no user token + createFileOptions = getCreateFileOptions() + fileID++ + treePath = fmt.Sprintf("new/file%d.txt", fileID) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath), &createFileOptions) + MakeRequest(t, req, http.StatusNotFound) + + // Test using repo "user2/repo1" where user4 is a NOT collaborator + createFileOptions = getCreateFileOptions() + fileID++ + treePath = fmt.Sprintf("new/file%d.txt", fileID) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &createFileOptions). + AddTokenAuth(token4) + MakeRequest(t, req, http.StatusForbidden) + + // Test creating a file in an empty repository + forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) { + reponame := "empty-repo-" + objectFormat.Name() + doAPICreateRepository(NewAPITestContext(t, "user2", reponame, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser), true, objectFormat)(t) + createFileOptions = getCreateFileOptions() + fileID++ + treePath = fmt.Sprintf("new/file%d.txt", fileID) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, reponame, treePath), &createFileOptions). + AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusCreated) + emptyRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: reponame}) // public repo + gitRepo, _ := gitrepo.OpenRepository(stdCtx.Background(), emptyRepo) + commitID, _ := gitRepo.GetBranchCommitID(createFileOptions.NewBranchName) + latestCommit, _ := gitRepo.GetCommitByPath(treePath) + expectedFileResponse := getExpectedFileResponseForCreate("user2/"+reponame, commitID, treePath, latestCommit.ID.String()) + DecodeJSON(t, resp, &fileResponse) + assert.EqualValues(t, expectedFileResponse.Content, fileResponse.Content) + assert.EqualValues(t, expectedFileResponse.Commit.SHA, fileResponse.Commit.SHA) + assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, fileResponse.Commit.HTMLURL) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, fileResponse.Commit.Author.Email) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, fileResponse.Commit.Author.Name) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Date, fileResponse.Commit.Author.Date) + assert.EqualValues(t, expectedFileResponse.Commit.Committer.Email, fileResponse.Commit.Committer.Email) + assert.EqualValues(t, expectedFileResponse.Commit.Committer.Name, fileResponse.Commit.Committer.Name) + assert.EqualValues(t, expectedFileResponse.Commit.Committer.Date, fileResponse.Commit.Committer.Date) + gitRepo.Close() + }) + }) +} diff --git a/tests/integration/api_repo_file_delete_test.go b/tests/integration/api_repo_file_delete_test.go new file mode 100644 index 0000000..7c93307 --- /dev/null +++ b/tests/integration/api_repo_file_delete_test.go @@ -0,0 +1,167 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" +) + +func getDeleteFileOptions() *api.DeleteFileOptions { + return &api.DeleteFileOptions{ + FileOptions: api.FileOptions{ + BranchName: "master", + NewBranchName: "master", + Message: "Removing the file new/file.txt", + Author: api.Identity{ + Name: "John Doe", + Email: "johndoe@example.com", + }, + Committer: api.Identity{ + Name: "Jane Doe", + Email: "janedoe@example.com", + }, + }, + SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885", + } +} + +func TestAPIDeleteFile(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16 + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo + repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo + fileID := 0 + + // Get user2's token + session := loginUser(t, user2.Name) + token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + // Get user4's token + session = loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // Test deleting a file in repo1 which user2 owns, try both with branch and empty branch + for _, branch := range [...]string{ + "master", // Branch + "", // Empty branch + } { + fileID++ + treePath := fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo1, treePath) + deleteFileOptions := getDeleteFileOptions() + deleteFileOptions.BranchName = branch + req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &deleteFileOptions). + AddTokenAuth(token2) + resp := MakeRequest(t, req, http.StatusOK) + var fileResponse api.FileResponse + DecodeJSON(t, resp, &fileResponse) + assert.NotNil(t, fileResponse) + assert.Nil(t, fileResponse.Content) + } + + // Test deleting file and making the delete in a new branch + fileID++ + treePath := fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo1, treePath) + deleteFileOptions := getDeleteFileOptions() + deleteFileOptions.BranchName = repo1.DefaultBranch + deleteFileOptions.NewBranchName = "new_branch" + req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &deleteFileOptions). + AddTokenAuth(token2) + resp := MakeRequest(t, req, http.StatusOK) + var fileResponse api.FileResponse + DecodeJSON(t, resp, &fileResponse) + assert.NotNil(t, fileResponse) + assert.Nil(t, fileResponse.Content) + assert.EqualValues(t, deleteFileOptions.Message+"\n", fileResponse.Commit.Message) + + // Test deleting file without a message + fileID++ + treePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo1, treePath) + deleteFileOptions = getDeleteFileOptions() + deleteFileOptions.Message = "" + req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &deleteFileOptions). + AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &fileResponse) + expectedMessage := "Delete " + treePath + "\n" + assert.EqualValues(t, expectedMessage, fileResponse.Commit.Message) + + // Test deleting a file with the wrong SHA + fileID++ + treePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo1, treePath) + deleteFileOptions = getDeleteFileOptions() + deleteFileOptions.SHA = "badsha" + req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &deleteFileOptions). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusBadRequest) + + // Test creating a file in repo16 by user4 who does not have write access + fileID++ + treePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo16, treePath) + deleteFileOptions = getDeleteFileOptions() + req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &deleteFileOptions). + AddTokenAuth(token4) + MakeRequest(t, req, http.StatusNotFound) + + // Tests a repo with no token given so will fail + fileID++ + treePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo16, treePath) + deleteFileOptions = getDeleteFileOptions() + req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &deleteFileOptions) + MakeRequest(t, req, http.StatusNotFound) + + // Test using access token for a private repo that the user of the token owns + fileID++ + treePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo16, treePath) + deleteFileOptions = getDeleteFileOptions() + req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &deleteFileOptions). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusOK) + + // Test using org repo "org3/repo3" where user2 is a collaborator + fileID++ + treePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(org3, repo3, treePath) + deleteFileOptions = getDeleteFileOptions() + req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath), &deleteFileOptions). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusOK) + + // Test using org repo "org3/repo3" with no user token + fileID++ + treePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(org3, repo3, treePath) + deleteFileOptions = getDeleteFileOptions() + req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath), &deleteFileOptions) + MakeRequest(t, req, http.StatusNotFound) + + // Test using repo "user2/repo1" where user4 is a NOT collaborator + fileID++ + treePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo1, treePath) + deleteFileOptions = getDeleteFileOptions() + req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &deleteFileOptions). + AddTokenAuth(token4) + MakeRequest(t, req, http.StatusForbidden) + }) +} diff --git a/tests/integration/api_repo_file_get_test.go b/tests/integration/api_repo_file_get_test.go new file mode 100644 index 0000000..1a4e670 --- /dev/null +++ b/tests/integration/api_repo_file_get_test.go @@ -0,0 +1,52 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/git" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIGetRawFileOrLFS(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // Test with raw file + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/media/README.md") + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "# repo1\n\nDescription for repo1", resp.Body.String()) + + // Test with LFS + onGiteaRun(t, func(t *testing.T, u *url.URL) { + httpContext := NewAPITestContext(t, "user2", "repo-lfs-test", auth_model.AccessTokenScopeWriteRepository) + doAPICreateRepository(httpContext, false, git.Sha1ObjectFormat, func(t *testing.T, repository api.Repository) { // FIXME: use forEachObjectFormat + u.Path = httpContext.GitPath() + dstPath := t.TempDir() + + u.Path = httpContext.GitPath() + u.User = url.UserPassword("user2", userPassword) + + t.Run("Clone", doGitClone(dstPath, u)) + + dstPath2 := t.TempDir() + + t.Run("Partial Clone", doPartialGitClone(dstPath2, u)) + + lfs, _ := lfsCommitAndPushTest(t, dstPath) + + reqLFS := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/media/"+lfs) + respLFS := MakeRequestNilResponseRecorder(t, reqLFS, http.StatusOK) + assert.Equal(t, littleSize, respLFS.Length) + + doAPIDeleteRepository(httpContext) + }) + }) +} diff --git a/tests/integration/api_repo_file_helpers.go b/tests/integration/api_repo_file_helpers.go new file mode 100644 index 0000000..4350092 --- /dev/null +++ b/tests/integration/api_repo_file_helpers.go @@ -0,0 +1,61 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "strings" + + "code.gitea.io/gitea/models" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + api "code.gitea.io/gitea/modules/structs" + files_service "code.gitea.io/gitea/services/repository/files" +) + +func createFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName, content string) (*api.FilesResponse, error) { + opts := &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: treePath, + ContentReader: strings.NewReader(content), + }, + }, + OldBranch: branchName, + Author: nil, + Committer: nil, + } + return files_service.ChangeRepoFiles(git.DefaultContext, repo, user, opts) +} + +func deleteFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName string) (*api.FilesResponse, error) { + opts := &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "delete", + TreePath: treePath, + }, + }, + OldBranch: branchName, + Author: nil, + Committer: nil, + } + return files_service.ChangeRepoFiles(git.DefaultContext, repo, user, opts) +} + +func createOrReplaceFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName, content string) error { + _, err := deleteFileInBranch(user, repo, treePath, branchName) + + if err != nil && !models.IsErrRepoFileDoesNotExist(err) { + return err + } + + _, err = createFileInBranch(user, repo, treePath, branchName, content) + return err +} + +func createFile(user *user_model.User, repo *repo_model.Repository, treePath string) (*api.FilesResponse, error) { + return createFileInBranch(user, repo, treePath, repo.DefaultBranch, "This is a NEW file") +} diff --git a/tests/integration/api_repo_file_update_test.go b/tests/integration/api_repo_file_update_test.go new file mode 100644 index 0000000..ac28e0c --- /dev/null +++ b/tests/integration/api_repo_file_update_test.go @@ -0,0 +1,275 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + stdCtx "context" + "encoding/base64" + "fmt" + "net/http" + "net/url" + "path/filepath" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" + + "github.com/stretchr/testify/assert" +) + +func getUpdateFileOptions() *api.UpdateFileOptions { + content := "This is updated text" + contentEncoded := base64.StdEncoding.EncodeToString([]byte(content)) + return &api.UpdateFileOptions{ + DeleteFileOptions: api.DeleteFileOptions{ + FileOptions: api.FileOptions{ + BranchName: "master", + NewBranchName: "master", + Message: "My update of new/file.txt", + Author: api.Identity{ + Name: "John Doe", + Email: "johndoe@example.com", + }, + Committer: api.Identity{ + Name: "Anne Doe", + Email: "annedoe@example.com", + }, + }, + SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885", + }, + ContentBase64: contentEncoded, + } +} + +func getExpectedFileResponseForUpdate(commitID, treePath, lastCommitSHA string) *api.FileResponse { + sha := "08bd14b2e2852529157324de9c226b3364e76136" + encoding := "base64" + content := "VGhpcyBpcyB1cGRhdGVkIHRleHQ=" + selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath + "?ref=master" + htmlURL := setting.AppURL + "user2/repo1/src/branch/master/" + treePath + gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/" + sha + downloadURL := setting.AppURL + "user2/repo1/raw/branch/master/" + treePath + return &api.FileResponse{ + Content: &api.ContentsResponse{ + Name: filepath.Base(treePath), + Path: treePath, + SHA: sha, + LastCommitSHA: lastCommitSHA, + Type: "file", + Size: 20, + Encoding: &encoding, + Content: &content, + URL: &selfURL, + HTMLURL: &htmlURL, + GitURL: &gitURL, + DownloadURL: &downloadURL, + Links: &api.FileLinksResponse{ + Self: &selfURL, + GitURL: &gitURL, + HTMLURL: &htmlURL, + }, + }, + Commit: &api.FileCommitResponse{ + CommitMeta: api.CommitMeta{ + URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/" + commitID, + SHA: commitID, + }, + HTMLURL: setting.AppURL + "user2/repo1/commit/" + commitID, + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "John Doe", + Email: "johndoe@example.com", + }, + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "Anne Doe", + Email: "annedoe@example.com", + }, + }, + Message: "My update of README.md\n", + }, + Verification: &api.PayloadCommitVerification{ + Verified: false, + Reason: "gpg.error.not_signed_commit", + Signature: "", + Payload: "", + }, + } +} + +func TestAPIUpdateFile(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16 + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo + repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo + fileID := 0 + + // Get user2's token + session := loginUser(t, user2.Name) + token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + // Get user4's token + session = loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // Test updating a file in repo1 which user2 owns, try both with branch and empty branch + for _, branch := range [...]string{ + "master", // Branch + "", // Empty branch + } { + fileID++ + treePath := fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo1, treePath) + updateFileOptions := getUpdateFileOptions() + updateFileOptions.BranchName = branch + req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &updateFileOptions). + AddTokenAuth(token2) + resp := MakeRequest(t, req, http.StatusOK) + gitRepo, _ := gitrepo.OpenRepository(stdCtx.Background(), repo1) + commitID, _ := gitRepo.GetBranchCommitID(updateFileOptions.NewBranchName) + lasCommit, _ := gitRepo.GetCommitByPath(treePath) + expectedFileResponse := getExpectedFileResponseForUpdate(commitID, treePath, lasCommit.ID.String()) + var fileResponse api.FileResponse + DecodeJSON(t, resp, &fileResponse) + assert.EqualValues(t, expectedFileResponse.Content, fileResponse.Content) + assert.EqualValues(t, expectedFileResponse.Commit.SHA, fileResponse.Commit.SHA) + assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, fileResponse.Commit.HTMLURL) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, fileResponse.Commit.Author.Email) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, fileResponse.Commit.Author.Name) + gitRepo.Close() + } + + // Test updating a file in a new branch + updateFileOptions := getUpdateFileOptions() + updateFileOptions.BranchName = repo1.DefaultBranch + updateFileOptions.NewBranchName = "new_branch" + fileID++ + treePath := fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo1, treePath) + req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &updateFileOptions). + AddTokenAuth(token2) + resp := MakeRequest(t, req, http.StatusOK) + var fileResponse api.FileResponse + DecodeJSON(t, resp, &fileResponse) + expectedSHA := "08bd14b2e2852529157324de9c226b3364e76136" + expectedHTMLURL := fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/new_branch/update/file%d.txt", fileID) + expectedDownloadURL := fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/new_branch/update/file%d.txt", fileID) + assert.EqualValues(t, expectedSHA, fileResponse.Content.SHA) + assert.EqualValues(t, expectedHTMLURL, *fileResponse.Content.HTMLURL) + assert.EqualValues(t, expectedDownloadURL, *fileResponse.Content.DownloadURL) + assert.EqualValues(t, updateFileOptions.Message+"\n", fileResponse.Commit.Message) + + // Test updating a file and renaming it + updateFileOptions = getUpdateFileOptions() + updateFileOptions.BranchName = repo1.DefaultBranch + fileID++ + treePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo1, treePath) + updateFileOptions.FromPath = treePath + treePath = "rename/" + treePath + req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &updateFileOptions). + AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &fileResponse) + expectedSHA = "08bd14b2e2852529157324de9c226b3364e76136" + expectedHTMLURL = fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/master/rename/update/file%d.txt", fileID) + expectedDownloadURL = fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/master/rename/update/file%d.txt", fileID) + assert.EqualValues(t, expectedSHA, fileResponse.Content.SHA) + assert.EqualValues(t, expectedHTMLURL, *fileResponse.Content.HTMLURL) + assert.EqualValues(t, expectedDownloadURL, *fileResponse.Content.DownloadURL) + + // Test updating a file without a message + updateFileOptions = getUpdateFileOptions() + updateFileOptions.Message = "" + updateFileOptions.BranchName = repo1.DefaultBranch + fileID++ + treePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo1, treePath) + req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &updateFileOptions). + AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &fileResponse) + expectedMessage := "Update " + treePath + "\n" + assert.EqualValues(t, expectedMessage, fileResponse.Commit.Message) + + // Test updating a file with the wrong SHA + fileID++ + treePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo1, treePath) + updateFileOptions = getUpdateFileOptions() + correctSHA := updateFileOptions.SHA + updateFileOptions.SHA = "badsha" + req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &updateFileOptions). + AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusUnprocessableEntity) + expectedAPIError := context.APIError{ + Message: "sha does not match [given: " + updateFileOptions.SHA + ", expected: " + correctSHA + "]", + URL: setting.API.SwaggerURL, + } + var apiError context.APIError + DecodeJSON(t, resp, &apiError) + assert.Equal(t, expectedAPIError, apiError) + + // Test creating a file in repo1 by user4 who does not have write access + fileID++ + treePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo16, treePath) + updateFileOptions = getUpdateFileOptions() + req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &updateFileOptions). + AddTokenAuth(token4) + MakeRequest(t, req, http.StatusNotFound) + + // Tests a repo with no token given so will fail + fileID++ + treePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo16, treePath) + updateFileOptions = getUpdateFileOptions() + req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &updateFileOptions) + MakeRequest(t, req, http.StatusNotFound) + + // Test using access token for a private repo that the user of the token owns + fileID++ + treePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo16, treePath) + updateFileOptions = getUpdateFileOptions() + req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &updateFileOptions). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusOK) + + // Test using org repo "org3/repo3" where user2 is a collaborator + fileID++ + treePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(org3, repo3, treePath) + updateFileOptions = getUpdateFileOptions() + req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath), &updateFileOptions). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusOK) + + // Test using org repo "org3/repo3" with no user token + fileID++ + treePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(org3, repo3, treePath) + updateFileOptions = getUpdateFileOptions() + req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath), &updateFileOptions) + MakeRequest(t, req, http.StatusNotFound) + + // Test using repo "user2/repo1" where user4 is a NOT collaborator + fileID++ + treePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo1, treePath) + updateFileOptions = getUpdateFileOptions() + req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &updateFileOptions). + AddTokenAuth(token4) + MakeRequest(t, req, http.StatusForbidden) + }) +} diff --git a/tests/integration/api_repo_files_change_test.go b/tests/integration/api_repo_files_change_test.go new file mode 100644 index 0000000..fb3ae5e --- /dev/null +++ b/tests/integration/api_repo_files_change_test.go @@ -0,0 +1,311 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + stdCtx "context" + "encoding/base64" + "fmt" + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" + + "github.com/stretchr/testify/assert" +) + +func getChangeFilesOptions() *api.ChangeFilesOptions { + newContent := "This is new text" + updateContent := "This is updated text" + newContentEncoded := base64.StdEncoding.EncodeToString([]byte(newContent)) + updateContentEncoded := base64.StdEncoding.EncodeToString([]byte(updateContent)) + return &api.ChangeFilesOptions{ + FileOptions: api.FileOptions{ + BranchName: "master", + NewBranchName: "master", + Message: "My update of new/file.txt", + Author: api.Identity{ + Name: "Anne Doe", + Email: "annedoe@example.com", + }, + Committer: api.Identity{ + Name: "John Doe", + Email: "johndoe@example.com", + }, + }, + Files: []*api.ChangeFileOperation{ + { + Operation: "create", + ContentBase64: newContentEncoded, + }, + { + Operation: "update", + ContentBase64: updateContentEncoded, + SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885", + }, + { + Operation: "delete", + SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885", + }, + }, + } +} + +func TestAPIChangeFiles(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16 + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo + repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo + fileID := 0 + + // Get user2's token + session := loginUser(t, user2.Name) + token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + // Get user4's token + session = loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // Test changing files in repo1 which user2 owns, try both with branch and empty branch + for _, branch := range [...]string{ + "master", // Branch + "", // Empty branch + } { + fileID++ + createTreePath := fmt.Sprintf("new/file%d.txt", fileID) + updateTreePath := fmt.Sprintf("update/file%d.txt", fileID) + deleteTreePath := fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo1, updateTreePath) + createFile(user2, repo1, deleteTreePath) + changeFilesOptions := getChangeFilesOptions() + changeFilesOptions.BranchName = branch + changeFilesOptions.Files[0].Path = createTreePath + changeFilesOptions.Files[1].Path = updateTreePath + changeFilesOptions.Files[2].Path = deleteTreePath + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents", user2.Name, repo1.Name), &changeFilesOptions). + AddTokenAuth(token2) + resp := MakeRequest(t, req, http.StatusCreated) + gitRepo, _ := gitrepo.OpenRepository(stdCtx.Background(), repo1) + commitID, _ := gitRepo.GetBranchCommitID(changeFilesOptions.NewBranchName) + createLasCommit, _ := gitRepo.GetCommitByPath(createTreePath) + updateLastCommit, _ := gitRepo.GetCommitByPath(updateTreePath) + expectedCreateFileResponse := getExpectedFileResponseForCreate(fmt.Sprintf("%v/%v", user2.Name, repo1.Name), commitID, createTreePath, createLasCommit.ID.String()) + expectedUpdateFileResponse := getExpectedFileResponseForUpdate(commitID, updateTreePath, updateLastCommit.ID.String()) + var filesResponse api.FilesResponse + DecodeJSON(t, resp, &filesResponse) + + // check create file + assert.EqualValues(t, expectedCreateFileResponse.Content, filesResponse.Files[0]) + + // check update file + assert.EqualValues(t, expectedUpdateFileResponse.Content, filesResponse.Files[1]) + + // test commit info + assert.EqualValues(t, expectedCreateFileResponse.Commit.SHA, filesResponse.Commit.SHA) + assert.EqualValues(t, expectedCreateFileResponse.Commit.HTMLURL, filesResponse.Commit.HTMLURL) + assert.EqualValues(t, expectedCreateFileResponse.Commit.Author.Email, filesResponse.Commit.Author.Email) + assert.EqualValues(t, expectedCreateFileResponse.Commit.Author.Name, filesResponse.Commit.Author.Name) + assert.EqualValues(t, expectedCreateFileResponse.Commit.Committer.Email, filesResponse.Commit.Committer.Email) + assert.EqualValues(t, expectedCreateFileResponse.Commit.Committer.Name, filesResponse.Commit.Committer.Name) + + // test delete file + assert.Nil(t, filesResponse.Files[2]) + + gitRepo.Close() + } + + // Test changing files in a new branch + changeFilesOptions := getChangeFilesOptions() + changeFilesOptions.BranchName = repo1.DefaultBranch + changeFilesOptions.NewBranchName = "new_branch" + fileID++ + createTreePath := fmt.Sprintf("new/file%d.txt", fileID) + updateTreePath := fmt.Sprintf("update/file%d.txt", fileID) + deleteTreePath := fmt.Sprintf("delete/file%d.txt", fileID) + changeFilesOptions.Files[0].Path = createTreePath + changeFilesOptions.Files[1].Path = updateTreePath + changeFilesOptions.Files[2].Path = deleteTreePath + createFile(user2, repo1, updateTreePath) + createFile(user2, repo1, deleteTreePath) + url := fmt.Sprintf("/api/v1/repos/%s/%s/contents", user2.Name, repo1.Name) + req := NewRequestWithJSON(t, "POST", url, &changeFilesOptions). + AddTokenAuth(token2) + resp := MakeRequest(t, req, http.StatusCreated) + var filesResponse api.FilesResponse + DecodeJSON(t, resp, &filesResponse) + expectedCreateSHA := "a635aa942442ddfdba07468cf9661c08fbdf0ebf" + expectedCreateHTMLURL := fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/new_branch/new/file%d.txt", fileID) + expectedCreateDownloadURL := fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/new_branch/new/file%d.txt", fileID) + expectedUpdateSHA := "08bd14b2e2852529157324de9c226b3364e76136" + expectedUpdateHTMLURL := fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/new_branch/update/file%d.txt", fileID) + expectedUpdateDownloadURL := fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/new_branch/update/file%d.txt", fileID) + assert.EqualValues(t, expectedCreateSHA, filesResponse.Files[0].SHA) + assert.EqualValues(t, expectedCreateHTMLURL, *filesResponse.Files[0].HTMLURL) + assert.EqualValues(t, expectedCreateDownloadURL, *filesResponse.Files[0].DownloadURL) + assert.EqualValues(t, expectedUpdateSHA, filesResponse.Files[1].SHA) + assert.EqualValues(t, expectedUpdateHTMLURL, *filesResponse.Files[1].HTMLURL) + assert.EqualValues(t, expectedUpdateDownloadURL, *filesResponse.Files[1].DownloadURL) + assert.Nil(t, filesResponse.Files[2]) + + assert.EqualValues(t, changeFilesOptions.Message+"\n", filesResponse.Commit.Message) + + // Test updating a file and renaming it + changeFilesOptions = getChangeFilesOptions() + changeFilesOptions.BranchName = repo1.DefaultBranch + fileID++ + updateTreePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo1, updateTreePath) + changeFilesOptions.Files = []*api.ChangeFileOperation{changeFilesOptions.Files[1]} + changeFilesOptions.Files[0].FromPath = updateTreePath + changeFilesOptions.Files[0].Path = "rename/" + updateTreePath + req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions). + AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, &filesResponse) + expectedUpdateSHA = "08bd14b2e2852529157324de9c226b3364e76136" + expectedUpdateHTMLURL = fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/master/rename/update/file%d.txt", fileID) + expectedUpdateDownloadURL = fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/master/rename/update/file%d.txt", fileID) + assert.EqualValues(t, expectedUpdateSHA, filesResponse.Files[0].SHA) + assert.EqualValues(t, expectedUpdateHTMLURL, *filesResponse.Files[0].HTMLURL) + assert.EqualValues(t, expectedUpdateDownloadURL, *filesResponse.Files[0].DownloadURL) + + // Test updating a file without a message + changeFilesOptions = getChangeFilesOptions() + changeFilesOptions.Message = "" + changeFilesOptions.BranchName = repo1.DefaultBranch + fileID++ + createTreePath = fmt.Sprintf("new/file%d.txt", fileID) + updateTreePath = fmt.Sprintf("update/file%d.txt", fileID) + deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID) + changeFilesOptions.Files[0].Path = createTreePath + changeFilesOptions.Files[1].Path = updateTreePath + changeFilesOptions.Files[2].Path = deleteTreePath + createFile(user2, repo1, updateTreePath) + createFile(user2, repo1, deleteTreePath) + req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions). + AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, &filesResponse) + expectedMessage := fmt.Sprintf("Add %v\nUpdate %v\nDelete %v\n", createTreePath, updateTreePath, deleteTreePath) + assert.EqualValues(t, expectedMessage, filesResponse.Commit.Message) + + // Test updating a file with the wrong SHA + fileID++ + updateTreePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo1, updateTreePath) + changeFilesOptions = getChangeFilesOptions() + changeFilesOptions.Files = []*api.ChangeFileOperation{changeFilesOptions.Files[1]} + changeFilesOptions.Files[0].Path = updateTreePath + correctSHA := changeFilesOptions.Files[0].SHA + changeFilesOptions.Files[0].SHA = "badsha" + req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions). + AddTokenAuth(token2) + resp = MakeRequest(t, req, http.StatusUnprocessableEntity) + expectedAPIError := context.APIError{ + Message: "sha does not match [given: " + changeFilesOptions.Files[0].SHA + ", expected: " + correctSHA + "]", + URL: setting.API.SwaggerURL, + } + var apiError context.APIError + DecodeJSON(t, resp, &apiError) + assert.Equal(t, expectedAPIError, apiError) + + // Test creating a file in repo1 by user4 who does not have write access + fileID++ + createTreePath = fmt.Sprintf("new/file%d.txt", fileID) + updateTreePath = fmt.Sprintf("update/file%d.txt", fileID) + deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo16, updateTreePath) + createFile(user2, repo16, deleteTreePath) + changeFilesOptions = getChangeFilesOptions() + changeFilesOptions.Files[0].Path = createTreePath + changeFilesOptions.Files[1].Path = updateTreePath + changeFilesOptions.Files[2].Path = deleteTreePath + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents", user2.Name, repo16.Name), &changeFilesOptions). + AddTokenAuth(token4) + MakeRequest(t, req, http.StatusNotFound) + + // Tests a repo with no token given so will fail + fileID++ + createTreePath = fmt.Sprintf("new/file%d.txt", fileID) + updateTreePath = fmt.Sprintf("update/file%d.txt", fileID) + deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo16, updateTreePath) + createFile(user2, repo16, deleteTreePath) + changeFilesOptions = getChangeFilesOptions() + changeFilesOptions.Files[0].Path = createTreePath + changeFilesOptions.Files[1].Path = updateTreePath + changeFilesOptions.Files[2].Path = deleteTreePath + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents", user2.Name, repo16.Name), &changeFilesOptions) + MakeRequest(t, req, http.StatusNotFound) + + // Test using access token for a private repo that the user of the token owns + fileID++ + createTreePath = fmt.Sprintf("new/file%d.txt", fileID) + updateTreePath = fmt.Sprintf("update/file%d.txt", fileID) + deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo16, updateTreePath) + createFile(user2, repo16, deleteTreePath) + changeFilesOptions = getChangeFilesOptions() + changeFilesOptions.Files[0].Path = createTreePath + changeFilesOptions.Files[1].Path = updateTreePath + changeFilesOptions.Files[2].Path = deleteTreePath + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents", user2.Name, repo16.Name), &changeFilesOptions). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusCreated) + + // Test using org repo "org3/repo3" where user2 is a collaborator + fileID++ + createTreePath = fmt.Sprintf("new/file%d.txt", fileID) + updateTreePath = fmt.Sprintf("update/file%d.txt", fileID) + deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(org3, repo3, updateTreePath) + createFile(org3, repo3, deleteTreePath) + changeFilesOptions = getChangeFilesOptions() + changeFilesOptions.Files[0].Path = createTreePath + changeFilesOptions.Files[1].Path = updateTreePath + changeFilesOptions.Files[2].Path = deleteTreePath + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents", org3.Name, repo3.Name), &changeFilesOptions). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusCreated) + + // Test using org repo "org3/repo3" with no user token + fileID++ + createTreePath = fmt.Sprintf("new/file%d.txt", fileID) + updateTreePath = fmt.Sprintf("update/file%d.txt", fileID) + deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(org3, repo3, updateTreePath) + createFile(org3, repo3, deleteTreePath) + changeFilesOptions = getChangeFilesOptions() + changeFilesOptions.Files[0].Path = createTreePath + changeFilesOptions.Files[1].Path = updateTreePath + changeFilesOptions.Files[2].Path = deleteTreePath + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents", org3.Name, repo3.Name), &changeFilesOptions) + MakeRequest(t, req, http.StatusNotFound) + + // Test using repo "user2/repo1" where user4 is a NOT collaborator + fileID++ + createTreePath = fmt.Sprintf("new/file%d.txt", fileID) + updateTreePath = fmt.Sprintf("update/file%d.txt", fileID) + deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo1, updateTreePath) + createFile(user2, repo1, deleteTreePath) + changeFilesOptions = getChangeFilesOptions() + changeFilesOptions.Files[0].Path = createTreePath + changeFilesOptions.Files[1].Path = updateTreePath + changeFilesOptions.Files[2].Path = deleteTreePath + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents", user2.Name, repo1.Name), &changeFilesOptions). + AddTokenAuth(token4) + MakeRequest(t, req, http.StatusForbidden) + }) +} diff --git a/tests/integration/api_repo_get_contents_list_test.go b/tests/integration/api_repo_get_contents_list_test.go new file mode 100644 index 0000000..e76ccd9 --- /dev/null +++ b/tests/integration/api_repo_get_contents_list_test.go @@ -0,0 +1,172 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "path/filepath" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + repo_service "code.gitea.io/gitea/services/repository" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func getExpectedContentsListResponseForContents(ref, refType, lastCommitSHA string) []*api.ContentsResponse { + treePath := "README.md" + sha := "4b4851ad51df6a7d9f25c979345979eaeb5b349f" + selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath + "?ref=" + ref + htmlURL := setting.AppURL + "user2/repo1/src/" + refType + "/" + ref + "/" + treePath + gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/" + sha + downloadURL := setting.AppURL + "user2/repo1/raw/" + refType + "/" + ref + "/" + treePath + return []*api.ContentsResponse{ + { + Name: filepath.Base(treePath), + Path: treePath, + SHA: sha, + LastCommitSHA: lastCommitSHA, + Type: "file", + Size: 30, + URL: &selfURL, + HTMLURL: &htmlURL, + GitURL: &gitURL, + DownloadURL: &downloadURL, + Links: &api.FileLinksResponse{ + Self: &selfURL, + GitURL: &gitURL, + HTMLURL: &htmlURL, + }, + }, + } +} + +func TestAPIGetContentsList(t *testing.T) { + onGiteaRun(t, testAPIGetContentsList) +} + +func testAPIGetContentsList(t *testing.T, u *url.URL) { + /*** SETUP ***/ + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16 + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo + repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo + treePath := "" // root dir + + // Get user2's token + session := loginUser(t, user2.Name) + token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + // Get user4's token + session = loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + // Get the commit ID of the default branch + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo1) + require.NoError(t, err) + defer gitRepo.Close() + + // Make a new branch in repo1 + newBranch := "test_branch" + err = repo_service.CreateNewBranch(git.DefaultContext, user2, repo1, gitRepo, repo1.DefaultBranch, newBranch) + require.NoError(t, err) + + commitID, _ := gitRepo.GetBranchCommitID(repo1.DefaultBranch) + // Make a new tag in repo1 + newTag := "test_tag" + err = gitRepo.CreateTag(newTag, commitID) + require.NoError(t, err) + /*** END SETUP ***/ + + // ref is default ref + ref := repo1.DefaultBranch + refType := "branch" + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) + resp := MakeRequest(t, req, http.StatusOK) + var contentsListResponse []*api.ContentsResponse + DecodeJSON(t, resp, &contentsListResponse) + assert.NotNil(t, contentsListResponse) + lastCommit, err := gitRepo.GetCommitByPath("README.md") + require.NoError(t, err) + expectedContentsListResponse := getExpectedContentsListResponseForContents(ref, refType, lastCommit.ID.String()) + assert.EqualValues(t, expectedContentsListResponse, contentsListResponse) + + // No ref + refType = "branch" + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &contentsListResponse) + assert.NotNil(t, contentsListResponse) + + expectedContentsListResponse = getExpectedContentsListResponseForContents(repo1.DefaultBranch, refType, lastCommit.ID.String()) + assert.EqualValues(t, expectedContentsListResponse, contentsListResponse) + + // ref is the branch we created above in setup + ref = newBranch + refType = "branch" + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &contentsListResponse) + assert.NotNil(t, contentsListResponse) + branchCommit, err := gitRepo.GetBranchCommit(ref) + require.NoError(t, err) + lastCommit, err = branchCommit.GetCommitByPath("README.md") + require.NoError(t, err) + expectedContentsListResponse = getExpectedContentsListResponseForContents(ref, refType, lastCommit.ID.String()) + assert.EqualValues(t, expectedContentsListResponse, contentsListResponse) + + // ref is the new tag we created above in setup + ref = newTag + refType = "tag" + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &contentsListResponse) + assert.NotNil(t, contentsListResponse) + tagCommit, err := gitRepo.GetTagCommit(ref) + require.NoError(t, err) + lastCommit, err = tagCommit.GetCommitByPath("README.md") + require.NoError(t, err) + expectedContentsListResponse = getExpectedContentsListResponseForContents(ref, refType, lastCommit.ID.String()) + assert.EqualValues(t, expectedContentsListResponse, contentsListResponse) + + // ref is a commit + ref = commitID + refType = "commit" + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &contentsListResponse) + assert.NotNil(t, contentsListResponse) + expectedContentsListResponse = getExpectedContentsListResponseForContents(ref, refType, commitID) + assert.EqualValues(t, expectedContentsListResponse, contentsListResponse) + + // Test file contents a file with a bad ref + ref = "badref" + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) + MakeRequest(t, req, http.StatusNotFound) + + // Test accessing private ref with user token that does not have access - should fail + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath). + AddTokenAuth(token4) + MakeRequest(t, req, http.StatusNotFound) + + // Test access private ref of owner of token + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/readme.md", user2.Name, repo16.Name). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusOK) + + // Test access of org org3 private repo file by owner user2 + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusOK) +} diff --git a/tests/integration/api_repo_get_contents_test.go b/tests/integration/api_repo_get_contents_test.go new file mode 100644 index 0000000..cb321b8 --- /dev/null +++ b/tests/integration/api_repo_get_contents_test.go @@ -0,0 +1,196 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "io" + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + repo_service "code.gitea.io/gitea/services/repository" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func getExpectedContentsResponseForContents(ref, refType, lastCommitSHA string) *api.ContentsResponse { + treePath := "README.md" + sha := "4b4851ad51df6a7d9f25c979345979eaeb5b349f" + encoding := "base64" + content := "IyByZXBvMQoKRGVzY3JpcHRpb24gZm9yIHJlcG8x" + selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath + "?ref=" + ref + htmlURL := setting.AppURL + "user2/repo1/src/" + refType + "/" + ref + "/" + treePath + gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/" + sha + downloadURL := setting.AppURL + "user2/repo1/raw/" + refType + "/" + ref + "/" + treePath + return &api.ContentsResponse{ + Name: treePath, + Path: treePath, + SHA: sha, + LastCommitSHA: lastCommitSHA, + Type: "file", + Size: 30, + Encoding: &encoding, + Content: &content, + URL: &selfURL, + HTMLURL: &htmlURL, + GitURL: &gitURL, + DownloadURL: &downloadURL, + Links: &api.FileLinksResponse{ + Self: &selfURL, + GitURL: &gitURL, + HTMLURL: &htmlURL, + }, + } +} + +func TestAPIGetContents(t *testing.T) { + onGiteaRun(t, testAPIGetContents) +} + +func testAPIGetContents(t *testing.T, u *url.URL) { + /*** SETUP ***/ + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16 + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo + repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo + treePath := "README.md" + + // Get user2's token + session := loginUser(t, user2.Name) + token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + // Get user4's token + session = loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + // Get the commit ID of the default branch + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo1) + require.NoError(t, err) + defer gitRepo.Close() + + // Make a new branch in repo1 + newBranch := "test_branch" + err = repo_service.CreateNewBranch(git.DefaultContext, user2, repo1, gitRepo, repo1.DefaultBranch, newBranch) + require.NoError(t, err) + + commitID, err := gitRepo.GetBranchCommitID(repo1.DefaultBranch) + require.NoError(t, err) + // Make a new tag in repo1 + newTag := "test_tag" + err = gitRepo.CreateTag(newTag, commitID) + require.NoError(t, err) + /*** END SETUP ***/ + + // ref is default ref + ref := repo1.DefaultBranch + refType := "branch" + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) + resp := MakeRequest(t, req, http.StatusOK) + var contentsResponse api.ContentsResponse + DecodeJSON(t, resp, &contentsResponse) + assert.NotNil(t, contentsResponse) + lastCommit, _ := gitRepo.GetCommitByPath("README.md") + expectedContentsResponse := getExpectedContentsResponseForContents(ref, refType, lastCommit.ID.String()) + assert.EqualValues(t, *expectedContentsResponse, contentsResponse) + + // No ref + refType = "branch" + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &contentsResponse) + assert.NotNil(t, contentsResponse) + expectedContentsResponse = getExpectedContentsResponseForContents(repo1.DefaultBranch, refType, lastCommit.ID.String()) + assert.EqualValues(t, *expectedContentsResponse, contentsResponse) + + // ref is the branch we created above in setup + ref = newBranch + refType = "branch" + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &contentsResponse) + assert.NotNil(t, contentsResponse) + branchCommit, _ := gitRepo.GetBranchCommit(ref) + lastCommit, _ = branchCommit.GetCommitByPath("README.md") + expectedContentsResponse = getExpectedContentsResponseForContents(ref, refType, lastCommit.ID.String()) + assert.EqualValues(t, *expectedContentsResponse, contentsResponse) + + // ref is the new tag we created above in setup + ref = newTag + refType = "tag" + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &contentsResponse) + assert.NotNil(t, contentsResponse) + tagCommit, _ := gitRepo.GetTagCommit(ref) + lastCommit, _ = tagCommit.GetCommitByPath("README.md") + expectedContentsResponse = getExpectedContentsResponseForContents(ref, refType, lastCommit.ID.String()) + assert.EqualValues(t, *expectedContentsResponse, contentsResponse) + + // ref is a commit + ref = commitID + refType = "commit" + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &contentsResponse) + assert.NotNil(t, contentsResponse) + expectedContentsResponse = getExpectedContentsResponseForContents(ref, refType, commitID) + assert.EqualValues(t, *expectedContentsResponse, contentsResponse) + + // Test file contents a file with a bad ref + ref = "badref" + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) + MakeRequest(t, req, http.StatusNotFound) + + // Test accessing private ref with user token that does not have access - should fail + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath). + AddTokenAuth(token4) + MakeRequest(t, req, http.StatusNotFound) + + // Test access private ref of owner of token + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/readme.md", user2.Name, repo16.Name). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusOK) + + // Test access of org org3 private repo file by owner user2 + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusOK) +} + +func TestAPIGetContentsRefFormats(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + file := "README.md" + sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d" + content := "# repo1\n\nDescription for repo1" + + noRef := setting.AppURL + "api/v1/repos/user2/repo1/raw/" + file + refInPath := setting.AppURL + "api/v1/repos/user2/repo1/raw/" + sha + "/" + file + refInQuery := setting.AppURL + "api/v1/repos/user2/repo1/raw/" + file + "?ref=" + sha + + resp := MakeRequest(t, NewRequest(t, http.MethodGet, noRef), http.StatusOK) + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.EqualValues(t, content, string(raw)) + + resp = MakeRequest(t, NewRequest(t, http.MethodGet, refInPath), http.StatusOK) + raw, err = io.ReadAll(resp.Body) + require.NoError(t, err) + assert.EqualValues(t, content, string(raw)) + + resp = MakeRequest(t, NewRequest(t, http.MethodGet, refInQuery), http.StatusOK) + raw, err = io.ReadAll(resp.Body) + require.NoError(t, err) + assert.EqualValues(t, content, string(raw)) + }) +} diff --git a/tests/integration/api_repo_git_blobs_test.go b/tests/integration/api_repo_git_blobs_test.go new file mode 100644 index 0000000..184362e --- /dev/null +++ b/tests/integration/api_repo_git_blobs_test.go @@ -0,0 +1,80 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIReposGitBlobs(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16 + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3 + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo + repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo + repo1ReadmeSHA := "65f1bf27bc3bf70f64657658635e66094edbcb4d" + repo3ReadmeSHA := "d56a3073c1dbb7b15963110a049d50cdb5db99fc" + repo16ReadmeSHA := "f90451c72ef61a7645293d17b47be7a8e983da57" + badSHA := "0000000000000000000000000000000000000000" + + // Login as User2. + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + // Test a public repo that anyone can GET the blob of + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", user2.Name, repo1.Name, repo1ReadmeSHA) + resp := MakeRequest(t, req, http.StatusOK) + var gitBlobResponse api.GitBlobResponse + DecodeJSON(t, resp, &gitBlobResponse) + assert.NotNil(t, gitBlobResponse) + expectedContent := "dHJlZSAyYTJmMWQ0NjcwNzI4YTJlMTAwNDllMzQ1YmQ3YTI3NjQ2OGJlYWI2CmF1dGhvciB1c2VyMSA8YWRkcmVzczFAZXhhbXBsZS5jb20+IDE0ODk5NTY0NzkgLTA0MDAKY29tbWl0dGVyIEV0aGFuIEtvZW5pZyA8ZXRoYW50a29lbmlnQGdtYWlsLmNvbT4gMTQ4OTk1NjQ3OSAtMDQwMAoKSW5pdGlhbCBjb21taXQK" + assert.Equal(t, expectedContent, gitBlobResponse.Content) + + // Tests a private repo with no token so will fail + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", user2.Name, repo16.Name, repo16ReadmeSHA) + MakeRequest(t, req, http.StatusNotFound) + + // Test using access token for a private repo that the user of the token owns + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", user2.Name, repo16.Name, repo16ReadmeSHA). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + // Test using bad sha + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", user2.Name, repo1.Name, badSHA) + MakeRequest(t, req, http.StatusBadRequest) + + // Test using org repo "org3/repo3" where user2 is a collaborator + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", org3.Name, repo3.Name, repo3ReadmeSHA). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + // Test using org repo "org3/repo3" where user2 is a collaborator + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", org3.Name, repo3.Name, repo3ReadmeSHA). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + // Test using org repo "org3/repo3" with no user token + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", org3.Name, repo3ReadmeSHA, repo3.Name) + MakeRequest(t, req, http.StatusNotFound) + + // Login as User4. + session = loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session) + + // Test using org repo "org3/repo3" where user4 is a NOT collaborator + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/d56a3073c1dbb7b15963110a049d50cdb5db99fc?access=%s", org3.Name, repo3.Name, token4) + MakeRequest(t, req, http.StatusNotFound) +} diff --git a/tests/integration/api_repo_git_commits_test.go b/tests/integration/api_repo_git_commits_test.go new file mode 100644 index 0000000..c4c626e --- /dev/null +++ b/tests/integration/api_repo_git_commits_test.go @@ -0,0 +1,233 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func compareCommitFiles(t *testing.T, expect []string, files []*api.CommitAffectedFiles) { + var actual []string + for i := range files { + actual = append(actual, files[i].Filename) + } + assert.ElementsMatch(t, expect, actual) +} + +func TestAPIReposGitCommits(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + // Login as User2. + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + // check invalid requests + req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/commits/12345", user.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/commits/..", user.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/commits/branch-not-exist", user.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + for _, ref := range [...]string{ + "master", // Branch + "v1.1", // Tag + "65f1", // short sha + "65f1bf27bc3bf70f64657658635e66094edbcb4d", // full sha + } { + req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/commits/%s", user.Name, ref). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + } +} + +func TestAPIReposGitCommitList(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + // Login as User2. + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + // Test getting commits (Page 1) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo20/commits?not=master&sha=remove-files-a", user.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var apiData []api.Commit + DecodeJSON(t, resp, &apiData) + + assert.Len(t, apiData, 2) + assert.EqualValues(t, "cfe3b3c1fd36fba04f9183287b106497e1afe986", apiData[0].CommitMeta.SHA) + compareCommitFiles(t, []string{"link_hi", "test.csv"}, apiData[0].Files) + assert.EqualValues(t, "c8e31bc7688741a5287fcde4fbb8fc129ca07027", apiData[1].CommitMeta.SHA) + compareCommitFiles(t, []string{"test.csv"}, apiData[1].Files) + + assert.EqualValues(t, "2", resp.Header().Get("X-Total")) +} + +func TestAPIReposGitCommitListNotMaster(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + // Login as User2. + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + // Test getting commits (Page 1) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/commits", user.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var apiData []api.Commit + DecodeJSON(t, resp, &apiData) + + assert.Len(t, apiData, 3) + assert.EqualValues(t, "69554a64c1e6030f051e5c3f94bfbd773cd6a324", apiData[0].CommitMeta.SHA) + compareCommitFiles(t, []string{"readme.md"}, apiData[0].Files) + assert.EqualValues(t, "27566bd5738fc8b4e3fef3c5e72cce608537bd95", apiData[1].CommitMeta.SHA) + compareCommitFiles(t, []string{"readme.md"}, apiData[1].Files) + assert.EqualValues(t, "5099b81332712fe655e34e8dd63574f503f61811", apiData[2].CommitMeta.SHA) + compareCommitFiles(t, []string{"readme.md"}, apiData[2].Files) + + assert.EqualValues(t, "3", resp.Header().Get("X-Total")) +} + +func TestAPIReposGitCommitListPage2Empty(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + // Login as User2. + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + // Test getting commits (Page=2) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/commits?page=2", user.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var apiData []api.Commit + DecodeJSON(t, resp, &apiData) + + assert.Empty(t, apiData) +} + +func TestAPIReposGitCommitListDifferentBranch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + // Login as User2. + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + // Test getting commits (Page=1, Branch=good-sign) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/commits?sha=good-sign", user.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var apiData []api.Commit + DecodeJSON(t, resp, &apiData) + + assert.Len(t, apiData, 1) + assert.Equal(t, "f27c2b2b03dcab38beaf89b0ab4ff61f6de63441", apiData[0].CommitMeta.SHA) + compareCommitFiles(t, []string{"readme.md"}, apiData[0].Files) +} + +func TestAPIReposGitCommitListWithoutSelectFields(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + // Login as User2. + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + // Test getting commits without files, verification, and stats + req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/commits?sha=good-sign&stat=false&files=false&verification=false", user.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var apiData []api.Commit + DecodeJSON(t, resp, &apiData) + + assert.Len(t, apiData, 1) + assert.Equal(t, "f27c2b2b03dcab38beaf89b0ab4ff61f6de63441", apiData[0].CommitMeta.SHA) + assert.Equal(t, (*api.CommitStats)(nil), apiData[0].Stats) + assert.Equal(t, (*api.PayloadCommitVerification)(nil), apiData[0].RepoCommit.Verification) + assert.Equal(t, ([]*api.CommitAffectedFiles)(nil), apiData[0].Files) +} + +func TestDownloadCommitDiffOrPatch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + // Login as User2. + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + // Test getting diff + reqDiff := NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/git/commits/f27c2b2b03dcab38beaf89b0ab4ff61f6de63441.diff", user.Name). + AddTokenAuth(token) + resp := MakeRequest(t, reqDiff, http.StatusOK) + assert.EqualValues(t, + "commit f27c2b2b03dcab38beaf89b0ab4ff61f6de63441\nAuthor: User2 <user2@example.com>\nDate: Sun Aug 6 19:55:01 2017 +0200\n\n good signed commit\n\ndiff --git a/readme.md b/readme.md\nnew file mode 100644\nindex 0000000..458121c\n--- /dev/null\n+++ b/readme.md\n@@ -0,0 +1 @@\n+good sign\n", + resp.Body.String()) + + // Test getting patch + reqPatch := NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/git/commits/f27c2b2b03dcab38beaf89b0ab4ff61f6de63441.patch", user.Name). + AddTokenAuth(token) + resp = MakeRequest(t, reqPatch, http.StatusOK) + assert.EqualValues(t, + "From f27c2b2b03dcab38beaf89b0ab4ff61f6de63441 Mon Sep 17 00:00:00 2001\nFrom: User2 <user2@example.com>\nDate: Sun, 6 Aug 2017 19:55:01 +0200\nSubject: [PATCH] good signed commit\n\n---\n readme.md | 1 +\n 1 file changed, 1 insertion(+)\n create mode 100644 readme.md\n\ndiff --git a/readme.md b/readme.md\nnew file mode 100644\nindex 0000000..458121c\n--- /dev/null\n+++ b/readme.md\n@@ -0,0 +1 @@\n+good sign\n", + resp.Body.String()) +} + +func TestGetFileHistory(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + // Login as User2. + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/commits?path=readme.md&sha=good-sign", user.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var apiData []api.Commit + DecodeJSON(t, resp, &apiData) + + assert.Len(t, apiData, 1) + assert.Equal(t, "f27c2b2b03dcab38beaf89b0ab4ff61f6de63441", apiData[0].CommitMeta.SHA) + compareCommitFiles(t, []string{"readme.md"}, apiData[0].Files) + + assert.EqualValues(t, "1", resp.Header().Get("X-Total")) +} + +func TestGetFileHistoryNotOnMaster(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + // Login as User2. + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo20/commits?path=test.csv&sha=add-csv¬=master", user.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var apiData []api.Commit + DecodeJSON(t, resp, &apiData) + + assert.Len(t, apiData, 1) + assert.Equal(t, "c8e31bc7688741a5287fcde4fbb8fc129ca07027", apiData[0].CommitMeta.SHA) + compareCommitFiles(t, []string{"test.csv"}, apiData[0].Files) + + assert.EqualValues(t, "1", resp.Header().Get("X-Total")) +} diff --git a/tests/integration/api_repo_git_hook_test.go b/tests/integration/api_repo_git_hook_test.go new file mode 100644 index 0000000..9917b41 --- /dev/null +++ b/tests/integration/api_repo_git_hook_test.go @@ -0,0 +1,196 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +const testHookContent = `#!/bin/bash + +echo Hello, World! +` + +func TestAPIListGitHooks(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 37}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // user1 is an admin user + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git", owner.Name, repo.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiGitHooks []*api.GitHook + DecodeJSON(t, resp, &apiGitHooks) + assert.Len(t, apiGitHooks, 3) + for _, apiGitHook := range apiGitHooks { + if apiGitHook.Name == "pre-receive" { + assert.True(t, apiGitHook.IsActive) + assert.Equal(t, testHookContent, apiGitHook.Content) + } else { + assert.False(t, apiGitHook.IsActive) + assert.Empty(t, apiGitHook.Content) + } + } +} + +func TestAPIListGitHooksNoHooks(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // user1 is an admin user + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git", owner.Name, repo.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiGitHooks []*api.GitHook + DecodeJSON(t, resp, &apiGitHooks) + assert.Len(t, apiGitHooks, 3) + for _, apiGitHook := range apiGitHooks { + assert.False(t, apiGitHook.IsActive) + assert.Empty(t, apiGitHook.Content) + } +} + +func TestAPIListGitHooksNoAccess(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git", owner.Name, repo.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) +} + +func TestAPIGetGitHook(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 37}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // user1 is an admin user + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiGitHook *api.GitHook + DecodeJSON(t, resp, &apiGitHook) + assert.True(t, apiGitHook.IsActive) + assert.Equal(t, testHookContent, apiGitHook.Content) +} + +func TestAPIGetGitHookNoAccess(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) +} + +func TestAPIEditGitHook(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // user1 is an admin user + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/hooks/git/pre-receive", + owner.Name, repo.Name) + req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditGitHookOption{ + Content: testHookContent, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiGitHook *api.GitHook + DecodeJSON(t, resp, &apiGitHook) + assert.True(t, apiGitHook.IsActive) + assert.Equal(t, testHookContent, apiGitHook.Content) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var apiGitHook2 *api.GitHook + DecodeJSON(t, resp, &apiGitHook2) + assert.True(t, apiGitHook2.IsActive) + assert.Equal(t, testHookContent, apiGitHook2.Content) +} + +func TestAPIEditGitHookNoAccess(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name) + req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditGitHookOption{ + Content: testHookContent, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) +} + +func TestAPIDeleteGitHook(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 37}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // user1 is an admin user + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var apiGitHook2 *api.GitHook + DecodeJSON(t, resp, &apiGitHook2) + assert.False(t, apiGitHook2.IsActive) + assert.Empty(t, apiGitHook2.Content) +} + +func TestAPIDeleteGitHookNoAccess(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) +} diff --git a/tests/integration/api_repo_git_notes_test.go b/tests/integration/api_repo_git_notes_test.go new file mode 100644 index 0000000..9f3e927 --- /dev/null +++ b/tests/integration/api_repo_git_notes_test.go @@ -0,0 +1,46 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" +) + +func TestAPIReposGitNotes(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + // Login as User2. + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + // check invalid requests + req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/notes/12345", user.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/notes/..", user.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + // check valid request + req = NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/notes/65f1bf27bc3bf70f64657658635e66094edbcb4d", user.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var apiData api.Note + DecodeJSON(t, resp, &apiData) + assert.Equal(t, "This is a test note\n", apiData.Message) + assert.NotEmpty(t, apiData.Commit.Files) + assert.NotNil(t, apiData.Commit.RepoCommit.Verification) + }) +} diff --git a/tests/integration/api_repo_git_ref_test.go b/tests/integration/api_repo_git_ref_test.go new file mode 100644 index 0000000..875752a --- /dev/null +++ b/tests/integration/api_repo_git_ref_test.go @@ -0,0 +1,39 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/tests" +) + +func TestAPIReposGitRefs(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + // Login as User2. + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + for _, ref := range [...]string{ + "refs/heads/master", // Branch + "refs/tags/v1.1", // Tag + } { + req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/%s", user.Name, ref). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + } + // Test getting all refs + req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/refs", user.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + // Test getting non-existent refs + req = NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/refs/heads/unknown", user.Name). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} diff --git a/tests/integration/api_repo_git_tags_test.go b/tests/integration/api_repo_git_tags_test.go new file mode 100644 index 0000000..c5883a8 --- /dev/null +++ b/tests/integration/api_repo_git_tags_test.go @@ -0,0 +1,88 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIGitTags(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + // Login as User2. + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + // Set up git config for the tagger + _ = git.NewCommand(git.DefaultContext, "config", "user.name").AddDynamicArguments(user.Name).Run(&git.RunOpts{Dir: repo.RepoPath()}) + _ = git.NewCommand(git.DefaultContext, "config", "user.email").AddDynamicArguments(user.Email).Run(&git.RunOpts{Dir: repo.RepoPath()}) + + gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo) + defer gitRepo.Close() + + commit, _ := gitRepo.GetBranchCommit("master") + lTagName := "lightweightTag" + gitRepo.CreateTag(lTagName, commit.ID.String()) + + aTagName := "annotatedTag" + aTagMessage := "my annotated message" + gitRepo.CreateAnnotatedTag(aTagName, aTagMessage, commit.ID.String()) + aTag, _ := gitRepo.GetTag(aTagName) + + // SHOULD work for annotated tags + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/tags/%s", user.Name, repo.Name, aTag.ID.String()). + AddTokenAuth(token) + res := MakeRequest(t, req, http.StatusOK) + + var tag *api.AnnotatedTag + DecodeJSON(t, res, &tag) + + assert.Equal(t, aTagName, tag.Tag) + assert.Equal(t, aTag.ID.String(), tag.SHA) + assert.Equal(t, commit.ID.String(), tag.Object.SHA) + assert.Equal(t, aTagMessage+"\n", tag.Message) + assert.Equal(t, user.Name, tag.Tagger.Name) + assert.Equal(t, user.Email, tag.Tagger.Email) + assert.Equal(t, util.URLJoin(repo.APIURL(), "git/tags", aTag.ID.String()), tag.URL) + + // Should NOT work for lightweight tags + badReq := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/tags/%s", user.Name, repo.Name, commit.ID.String()). + AddTokenAuth(token) + MakeRequest(t, badReq, http.StatusBadRequest) +} + +func TestAPIDeleteTagByName(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, owner.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequest(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/tags/delete-tag", owner.Name, repo.Name)). + AddTokenAuth(token) + _ = MakeRequest(t, req, http.StatusNoContent) + + // Make sure that actual releases can't be deleted outright + createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test") + + req = NewRequest(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/tags/release-tag", owner.Name, repo.Name)). + AddTokenAuth(token) + _ = MakeRequest(t, req, http.StatusConflict) +} diff --git a/tests/integration/api_repo_git_trees_test.go b/tests/integration/api_repo_git_trees_test.go new file mode 100644 index 0000000..8eec6d8 --- /dev/null +++ b/tests/integration/api_repo_git_trees_test.go @@ -0,0 +1,77 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/tests" +) + +func TestAPIReposGitTrees(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16 + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3 + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo + repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo + repo1TreeSHA := "65f1bf27bc3bf70f64657658635e66094edbcb4d" + repo3TreeSHA := "2a47ca4b614a9f5a43abbd5ad851a54a616ffee6" + repo16TreeSHA := "69554a64c1e6030f051e5c3f94bfbd773cd6a324" + badSHA := "0000000000000000000000000000000000000000" + + // Login as User2. + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + // Test a public repo that anyone can GET the tree of + for _, ref := range [...]string{ + "master", // Branch + repo1TreeSHA, // Tree SHA + } { + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s", user2.Name, repo1.Name, ref) + MakeRequest(t, req, http.StatusOK) + } + + // Tests a private repo with no token so will fail + for _, ref := range [...]string{ + "master", // Branch + repo1TreeSHA, // Tag + } { + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s", user2.Name, repo16.Name, ref) + MakeRequest(t, req, http.StatusNotFound) + } + + // Test using access token for a private repo that the user of the token owns + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s", user2.Name, repo16.Name, repo16TreeSHA). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + // Test using bad sha + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s", user2.Name, repo1.Name, badSHA) + MakeRequest(t, req, http.StatusBadRequest) + + // Test using org repo "org3/repo3" where user2 is a collaborator + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s", org3.Name, repo3.Name, repo3TreeSHA). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + // Test using org repo "org3/repo3" with no user token + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s", org3.Name, repo3TreeSHA, repo3.Name) + MakeRequest(t, req, http.StatusNotFound) + + // Login as User4. + session = loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session) + + // Test using org repo "org3/repo3" where user4 is a NOT collaborator + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/d56a3073c1dbb7b15963110a049d50cdb5db99fc?access=%s", org3.Name, repo3.Name, token4) + MakeRequest(t, req, http.StatusNotFound) +} diff --git a/tests/integration/api_repo_hook_test.go b/tests/integration/api_repo_hook_test.go new file mode 100644 index 0000000..9ae8119 --- /dev/null +++ b/tests/integration/api_repo_hook_test.go @@ -0,0 +1,45 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPICreateHook(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 37}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + // user1 is an admin user + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/%s", owner.Name, repo.Name, "hooks"), api.CreateHookOption{ + Type: "gitea", + Config: api.CreateHookOptionConfig{ + "content_type": "json", + "url": "http://example.com/", + }, + AuthorizationHeader: "Bearer s3cr3t", + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var apiHook *api.Hook + DecodeJSON(t, resp, &apiHook) + assert.Equal(t, "http://example.com/", apiHook.Config["url"]) + assert.Equal(t, "http://example.com/", apiHook.URL) + assert.Equal(t, "Bearer s3cr3t", apiHook.AuthorizationHeader) +} diff --git a/tests/integration/api_repo_languages_test.go b/tests/integration/api_repo_languages_test.go new file mode 100644 index 0000000..3572e2a --- /dev/null +++ b/tests/integration/api_repo_languages_test.go @@ -0,0 +1,50 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestRepoLanguages(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user2") + + // Request editor page + req := NewRequest(t, "GET", "/user2/repo1/_new/master/") + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + lastCommit := doc.GetInputValueByName("last_commit") + assert.NotEmpty(t, lastCommit) + + // Save new file to master branch + req = NewRequestWithValues(t, "POST", "/user2/repo1/_new/master/", map[string]string{ + "_csrf": doc.GetCSRF(), + "last_commit": lastCommit, + "tree_path": "test.go", + "content": "package main", + "commit_choice": "direct", + "commit_mail_id": "3", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + // let gitea calculate language stats + time.Sleep(time.Second) + + // Save new file to master branch + req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/languages") + resp = MakeRequest(t, req, http.StatusOK) + + var languages map[string]int64 + DecodeJSON(t, resp, &languages) + + assert.InDeltaMapValues(t, map[string]int64{"Go": 12}, languages, 0) + }) +} diff --git a/tests/integration/api_repo_lfs_locks_test.go b/tests/integration/api_repo_lfs_locks_test.go new file mode 100644 index 0000000..4ba01e6 --- /dev/null +++ b/tests/integration/api_repo_lfs_locks_test.go @@ -0,0 +1,181 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + "time" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPILFSLocksNotStarted(t *testing.T) { + defer tests.PrepareTestEnv(t)() + setting.LFS.StartServer = false + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + req := NewRequestf(t, "GET", "/%s/%s.git/info/lfs/locks", user.Name, repo.Name) + MakeRequest(t, req, http.StatusNotFound) + req = NewRequestf(t, "POST", "/%s/%s.git/info/lfs/locks", user.Name, repo.Name) + MakeRequest(t, req, http.StatusNotFound) + req = NewRequestf(t, "GET", "/%s/%s.git/info/lfs/locks/verify", user.Name, repo.Name) + MakeRequest(t, req, http.StatusNotFound) + req = NewRequestf(t, "GET", "/%s/%s.git/info/lfs/locks/10/unlock", user.Name, repo.Name) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestAPILFSLocksNotLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + setting.LFS.StartServer = true + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + req := NewRequestf(t, "GET", "/%s/%s.git/info/lfs/locks", user.Name, repo.Name) + req.Header.Set("Accept", lfs.MediaType) + resp := MakeRequest(t, req, http.StatusUnauthorized) + var lfsLockError api.LFSLockError + DecodeJSON(t, resp, &lfsLockError) + assert.Equal(t, "You must have pull access to list locks", lfsLockError.Message) +} + +func TestAPILFSLocksLogged(t *testing.T) { + defer tests.PrepareTestEnv(t)() + setting.LFS.StartServer = true + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // in org 3 + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // in org 3 + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // own by org 3 + + tests := []struct { + user *user_model.User + repo *repo_model.Repository + path string + httpResult int + addTime []int + }{ + {user: user2, repo: repo1, path: "foo/bar.zip", httpResult: http.StatusCreated, addTime: []int{0}}, + {user: user2, repo: repo1, path: "path/test", httpResult: http.StatusCreated, addTime: []int{0}}, + {user: user2, repo: repo1, path: "path/test", httpResult: http.StatusConflict}, + {user: user2, repo: repo1, path: "Foo/BaR.zip", httpResult: http.StatusConflict}, + {user: user2, repo: repo1, path: "Foo/Test/../subFOlder/../Relative/../BaR.zip", httpResult: http.StatusConflict}, + {user: user4, repo: repo1, path: "FoO/BaR.zip", httpResult: http.StatusUnauthorized}, + {user: user4, repo: repo1, path: "path/test-user4", httpResult: http.StatusUnauthorized}, + {user: user2, repo: repo1, path: "patH/Test-user4", httpResult: http.StatusCreated, addTime: []int{0}}, + {user: user2, repo: repo1, path: "some/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/path", httpResult: http.StatusCreated, addTime: []int{0}}, + + {user: user2, repo: repo3, path: "test/foo/bar.zip", httpResult: http.StatusCreated, addTime: []int{1, 2}}, + {user: user4, repo: repo3, path: "test/foo/bar.zip", httpResult: http.StatusConflict}, + {user: user4, repo: repo3, path: "test/foo/bar.bin", httpResult: http.StatusCreated, addTime: []int{1, 2}}, + } + + resultsTests := []struct { + user *user_model.User + repo *repo_model.Repository + totalCount int + oursCount int + theirsCount int + locksOwners []*user_model.User + locksTimes []time.Time + }{ + {user: user2, repo: repo1, totalCount: 4, oursCount: 4, theirsCount: 0, locksOwners: []*user_model.User{user2, user2, user2, user2}, locksTimes: []time.Time{}}, + {user: user2, repo: repo3, totalCount: 2, oursCount: 1, theirsCount: 1, locksOwners: []*user_model.User{user2, user4}, locksTimes: []time.Time{}}, + {user: user4, repo: repo3, totalCount: 2, oursCount: 1, theirsCount: 1, locksOwners: []*user_model.User{user2, user4}, locksTimes: []time.Time{}}, + } + + deleteTests := []struct { + user *user_model.User + repo *repo_model.Repository + lockID string + }{} + + // create locks + for _, test := range tests { + session := loginUser(t, test.user.Name) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks", test.repo.FullName()), map[string]string{"path": test.path}) + req.Header.Set("Accept", lfs.AcceptHeader) + req.Header.Set("Content-Type", lfs.MediaType) + resp := session.MakeRequest(t, req, test.httpResult) + if len(test.addTime) > 0 { + var lfsLock api.LFSLockResponse + DecodeJSON(t, resp, &lfsLock) + assert.Equal(t, test.user.Name, lfsLock.Lock.Owner.Name) + assert.EqualValues(t, lfsLock.Lock.LockedAt.Format(time.RFC3339), lfsLock.Lock.LockedAt.Format(time.RFC3339Nano)) // locked at should be rounded to second + for _, id := range test.addTime { + resultsTests[id].locksTimes = append(resultsTests[id].locksTimes, time.Now()) + } + } + } + + // check creation + for _, test := range resultsTests { + session := loginUser(t, test.user.Name) + req := NewRequestf(t, "GET", "/%s.git/info/lfs/locks", test.repo.FullName()) + req.Header.Set("Accept", lfs.AcceptHeader) + resp := session.MakeRequest(t, req, http.StatusOK) + var lfsLocks api.LFSLockList + DecodeJSON(t, resp, &lfsLocks) + assert.Len(t, lfsLocks.Locks, test.totalCount) + for i, lock := range lfsLocks.Locks { + assert.EqualValues(t, test.locksOwners[i].Name, lock.Owner.Name) + assert.WithinDuration(t, test.locksTimes[i], lock.LockedAt, 10*time.Second) + assert.EqualValues(t, lock.LockedAt.Format(time.RFC3339), lock.LockedAt.Format(time.RFC3339Nano)) // locked at should be rounded to second + } + + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks/verify", test.repo.FullName()), map[string]string{}) + req.Header.Set("Accept", lfs.AcceptHeader) + req.Header.Set("Content-Type", lfs.MediaType) + resp = session.MakeRequest(t, req, http.StatusOK) + var lfsLocksVerify api.LFSLockListVerify + DecodeJSON(t, resp, &lfsLocksVerify) + assert.Len(t, lfsLocksVerify.Ours, test.oursCount) + assert.Len(t, lfsLocksVerify.Theirs, test.theirsCount) + for _, lock := range lfsLocksVerify.Ours { + assert.EqualValues(t, test.user.Name, lock.Owner.Name) + deleteTests = append(deleteTests, struct { + user *user_model.User + repo *repo_model.Repository + lockID string + }{test.user, test.repo, lock.ID}) + } + for _, lock := range lfsLocksVerify.Theirs { + assert.NotEqual(t, test.user.DisplayName(), lock.Owner.Name) + } + } + + // remove all locks + for _, test := range deleteTests { + session := loginUser(t, test.user.Name) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks/%s/unlock", test.repo.FullName(), test.lockID), map[string]string{}) + req.Header.Set("Accept", lfs.AcceptHeader) + req.Header.Set("Content-Type", lfs.MediaType) + resp := session.MakeRequest(t, req, http.StatusOK) + var lfsLockRep api.LFSLockResponse + DecodeJSON(t, resp, &lfsLockRep) + assert.Equal(t, test.lockID, lfsLockRep.Lock.ID) + assert.Equal(t, test.user.Name, lfsLockRep.Lock.Owner.Name) + } + + // check that we don't have any lock + for _, test := range resultsTests { + session := loginUser(t, test.user.Name) + req := NewRequestf(t, "GET", "/%s.git/info/lfs/locks", test.repo.FullName()) + req.Header.Set("Accept", lfs.AcceptHeader) + resp := session.MakeRequest(t, req, http.StatusOK) + var lfsLocks api.LFSLockList + DecodeJSON(t, resp, &lfsLocks) + assert.Empty(t, lfsLocks.Locks) + } +} diff --git a/tests/integration/api_repo_lfs_migrate_test.go b/tests/integration/api_repo_lfs_migrate_test.go new file mode 100644 index 0000000..de85b91 --- /dev/null +++ b/tests/integration/api_repo_lfs_migrate_test.go @@ -0,0 +1,55 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "path" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/migrations" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIRepoLFSMigrateLocal(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + oldImportLocalPaths := setting.ImportLocalPaths + oldAllowLocalNetworks := setting.Migrations.AllowLocalNetworks + setting.ImportLocalPaths = true + setting.Migrations.AllowLocalNetworks = true + require.NoError(t, migrations.Init()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/migrate", &api.MigrateRepoOptions{ + CloneAddr: path.Join(setting.RepoRootPath, "migration/lfs-test.git"), + RepoOwnerID: user.ID, + RepoName: "lfs-test-local", + LFS: true, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, NoExpectedStatus) + assert.EqualValues(t, http.StatusCreated, resp.Code) + + store := lfs.NewContentStore() + ok, _ := store.Verify(lfs.Pointer{Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab041", Size: 6}) + assert.True(t, ok) + ok, _ = store.Verify(lfs.Pointer{Oid: "d6f175817f886ec6fbbc1515326465fa96c3bfd54a4ea06cfd6dbbd8340e0152", Size: 6}) + assert.True(t, ok) + + setting.ImportLocalPaths = oldImportLocalPaths + setting.Migrations.AllowLocalNetworks = oldAllowLocalNetworks + require.NoError(t, migrations.Init()) // reset old migration settings +} diff --git a/tests/integration/api_repo_lfs_test.go b/tests/integration/api_repo_lfs_test.go new file mode 100644 index 0000000..7a2a92d --- /dev/null +++ b/tests/integration/api_repo_lfs_test.go @@ -0,0 +1,487 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "net/http" + "path" + "strconv" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPILFSNotStarted(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + setting.LFS.StartServer = false + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + req := NewRequestf(t, "POST", "/%s/%s.git/info/lfs/objects/batch", user.Name, repo.Name) + MakeRequest(t, req, http.StatusNotFound) + req = NewRequestf(t, "PUT", "/%s/%s.git/info/lfs/objects/oid/10", user.Name, repo.Name) + MakeRequest(t, req, http.StatusNotFound) + req = NewRequestf(t, "GET", "/%s/%s.git/info/lfs/objects/oid/name", user.Name, repo.Name) + MakeRequest(t, req, http.StatusNotFound) + req = NewRequestf(t, "GET", "/%s/%s.git/info/lfs/objects/oid", user.Name, repo.Name) + MakeRequest(t, req, http.StatusNotFound) + req = NewRequestf(t, "POST", "/%s/%s.git/info/lfs/verify", user.Name, repo.Name) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestAPILFSMediaType(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + setting.LFS.StartServer = true + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + req := NewRequestf(t, "POST", "/%s/%s.git/info/lfs/objects/batch", user.Name, repo.Name) + MakeRequest(t, req, http.StatusUnsupportedMediaType) + req = NewRequestf(t, "POST", "/%s/%s.git/info/lfs/verify", user.Name, repo.Name) + MakeRequest(t, req, http.StatusUnsupportedMediaType) +} + +func createLFSTestRepository(t *testing.T, name string) *repo_model.Repository { + ctx := NewAPITestContext(t, "user2", "lfs-"+name+"-repo", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + t.Run("CreateRepo", doAPICreateRepository(ctx, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat + + repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "lfs-"+name+"-repo") + require.NoError(t, err) + + return repo +} + +func TestAPILFSBatch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + setting.LFS.StartServer = true + + repo := createLFSTestRepository(t, "batch") + + content := []byte("dummy1") + oid := storeObjectInRepo(t, repo.ID, &content) + defer git_model.RemoveLFSMetaObjectByOid(db.DefaultContext, repo.ID, oid) + + session := loginUser(t, "user2") + + newRequest := func(t testing.TB, br *lfs.BatchRequest) *RequestWrapper { + return NewRequestWithJSON(t, "POST", "/user2/lfs-batch-repo.git/info/lfs/objects/batch", br). + SetHeader("Accept", lfs.AcceptHeader). + SetHeader("Content-Type", lfs.MediaType) + } + decodeResponse := func(t *testing.T, b *bytes.Buffer) *lfs.BatchResponse { + var br lfs.BatchResponse + + require.NoError(t, json.Unmarshal(b.Bytes(), &br)) + return &br + } + + t.Run("InvalidJsonRequest", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := newRequest(t, nil) + + session.MakeRequest(t, req, http.StatusBadRequest) + }) + + t.Run("InvalidOperation", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := newRequest(t, &lfs.BatchRequest{ + Operation: "dummy", + }) + + session.MakeRequest(t, req, http.StatusBadRequest) + }) + + t.Run("InvalidPointer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := newRequest(t, &lfs.BatchRequest{ + Operation: "download", + Objects: []lfs.Pointer{ + {Oid: "dummy"}, + {Oid: oid, Size: -1}, + }, + }) + + resp := session.MakeRequest(t, req, http.StatusOK) + br := decodeResponse(t, resp.Body) + assert.Len(t, br.Objects, 2) + assert.Equal(t, "dummy", br.Objects[0].Oid) + assert.Equal(t, oid, br.Objects[1].Oid) + assert.Equal(t, int64(0), br.Objects[0].Size) + assert.Equal(t, int64(-1), br.Objects[1].Size) + assert.NotNil(t, br.Objects[0].Error) + assert.NotNil(t, br.Objects[1].Error) + assert.Equal(t, http.StatusUnprocessableEntity, br.Objects[0].Error.Code) + assert.Equal(t, http.StatusUnprocessableEntity, br.Objects[1].Error.Code) + assert.Equal(t, "Oid or size are invalid", br.Objects[0].Error.Message) + assert.Equal(t, "Oid or size are invalid", br.Objects[1].Error.Message) + }) + + t.Run("PointerSizeMismatch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := newRequest(t, &lfs.BatchRequest{ + Operation: "download", + Objects: []lfs.Pointer{ + {Oid: oid, Size: 1}, + }, + }) + + resp := session.MakeRequest(t, req, http.StatusOK) + br := decodeResponse(t, resp.Body) + assert.Len(t, br.Objects, 1) + assert.NotNil(t, br.Objects[0].Error) + assert.Equal(t, http.StatusUnprocessableEntity, br.Objects[0].Error.Code) + assert.Equal(t, "Object "+oid+" is not 1 bytes", br.Objects[0].Error.Message) + }) + + t.Run("Download", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("PointerNotInStore", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := newRequest(t, &lfs.BatchRequest{ + Operation: "download", + Objects: []lfs.Pointer{ + {Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab042", Size: 6}, + }, + }) + + resp := session.MakeRequest(t, req, http.StatusOK) + br := decodeResponse(t, resp.Body) + assert.Len(t, br.Objects, 1) + assert.NotNil(t, br.Objects[0].Error) + assert.Equal(t, http.StatusNotFound, br.Objects[0].Error.Code) + }) + + t.Run("MetaNotFound", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + p := lfs.Pointer{Oid: "05eeb4eb5be71f2dd291ca39157d6d9effd7d1ea19cbdc8a99411fe2a8f26a00", Size: 6} + + contentStore := lfs.NewContentStore() + exist, err := contentStore.Exists(p) + require.NoError(t, err) + assert.False(t, exist) + err = contentStore.Put(p, bytes.NewReader([]byte("dummy0"))) + require.NoError(t, err) + + req := newRequest(t, &lfs.BatchRequest{ + Operation: "download", + Objects: []lfs.Pointer{p}, + }) + + resp := session.MakeRequest(t, req, http.StatusOK) + br := decodeResponse(t, resp.Body) + assert.Len(t, br.Objects, 1) + assert.NotNil(t, br.Objects[0].Error) + assert.Equal(t, http.StatusNotFound, br.Objects[0].Error.Code) + }) + + t.Run("Success", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := newRequest(t, &lfs.BatchRequest{ + Operation: "download", + Objects: []lfs.Pointer{ + {Oid: oid, Size: 6}, + }, + }) + + resp := session.MakeRequest(t, req, http.StatusOK) + br := decodeResponse(t, resp.Body) + assert.Len(t, br.Objects, 1) + assert.Nil(t, br.Objects[0].Error) + assert.Contains(t, br.Objects[0].Actions, "download") + l := br.Objects[0].Actions["download"] + assert.NotNil(t, l) + assert.NotEmpty(t, l.Href) + }) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("FileTooBig", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + oldMaxFileSize := setting.LFS.MaxFileSize + setting.LFS.MaxFileSize = 2 + + req := newRequest(t, &lfs.BatchRequest{ + Operation: "upload", + Objects: []lfs.Pointer{ + {Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab042", Size: 6}, + }, + }) + + resp := session.MakeRequest(t, req, http.StatusOK) + br := decodeResponse(t, resp.Body) + assert.Len(t, br.Objects, 1) + assert.NotNil(t, br.Objects[0].Error) + assert.Equal(t, http.StatusUnprocessableEntity, br.Objects[0].Error.Code) + assert.Equal(t, "Size must be less than or equal to 2", br.Objects[0].Error.Message) + + setting.LFS.MaxFileSize = oldMaxFileSize + }) + + t.Run("AddMeta", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + p := lfs.Pointer{Oid: "05eeb4eb5be71f2dd291ca39157d6d9effd7d1ea19cbdc8a99411fe2a8f26a00", Size: 6} + + contentStore := lfs.NewContentStore() + exist, err := contentStore.Exists(p) + require.NoError(t, err) + assert.True(t, exist) + + repo2 := createLFSTestRepository(t, "batch2") + content := []byte("dummy0") + storeObjectInRepo(t, repo2.ID, &content) + + meta, err := git_model.GetLFSMetaObjectByOid(db.DefaultContext, repo.ID, p.Oid) + assert.Nil(t, meta) + assert.Equal(t, git_model.ErrLFSObjectNotExist, err) + + req := newRequest(t, &lfs.BatchRequest{ + Operation: "upload", + Objects: []lfs.Pointer{p}, + }) + + resp := session.MakeRequest(t, req, http.StatusOK) + br := decodeResponse(t, resp.Body) + assert.Len(t, br.Objects, 1) + assert.Nil(t, br.Objects[0].Error) + assert.Empty(t, br.Objects[0].Actions) + + meta, err = git_model.GetLFSMetaObjectByOid(db.DefaultContext, repo.ID, p.Oid) + require.NoError(t, err) + assert.NotNil(t, meta) + + // Cleanup + err = contentStore.Delete(p.RelativePath()) + require.NoError(t, err) + }) + + t.Run("AlreadyExists", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := newRequest(t, &lfs.BatchRequest{ + Operation: "upload", + Objects: []lfs.Pointer{ + {Oid: oid, Size: 6}, + }, + }) + + resp := session.MakeRequest(t, req, http.StatusOK) + br := decodeResponse(t, resp.Body) + assert.Len(t, br.Objects, 1) + assert.Nil(t, br.Objects[0].Error) + assert.Empty(t, br.Objects[0].Actions) + }) + + t.Run("NewFile", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := newRequest(t, &lfs.BatchRequest{ + Operation: "upload", + Objects: []lfs.Pointer{ + {Oid: "d6f175817f886ec6fbbc1515326465fa96c3bfd54a4ea06cfd6dbbd8340e0153", Size: 1}, + }, + }) + + resp := session.MakeRequest(t, req, http.StatusOK) + br := decodeResponse(t, resp.Body) + assert.Len(t, br.Objects, 1) + assert.Nil(t, br.Objects[0].Error) + assert.Contains(t, br.Objects[0].Actions, "upload") + ul := br.Objects[0].Actions["upload"] + assert.NotNil(t, ul) + assert.NotEmpty(t, ul.Href) + assert.Contains(t, br.Objects[0].Actions, "verify") + vl := br.Objects[0].Actions["verify"] + assert.NotNil(t, vl) + assert.NotEmpty(t, vl.Href) + }) + }) +} + +func TestAPILFSUpload(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + setting.LFS.StartServer = true + + repo := createLFSTestRepository(t, "upload") + + content := []byte("dummy3") + oid := storeObjectInRepo(t, repo.ID, &content) + defer git_model.RemoveLFSMetaObjectByOid(db.DefaultContext, repo.ID, oid) + + session := loginUser(t, "user2") + + newRequest := func(t testing.TB, p lfs.Pointer, content string) *RequestWrapper { + return NewRequestWithBody(t, "PUT", path.Join("/user2/lfs-upload-repo.git/info/lfs/objects/", p.Oid, strconv.FormatInt(p.Size, 10)), strings.NewReader(content)) + } + + t.Run("InvalidPointer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := newRequest(t, lfs.Pointer{Oid: "dummy"}, "") + + session.MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + + t.Run("AlreadyExistsInStore", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + p := lfs.Pointer{Oid: "83de2e488b89a0aa1c97496b888120a28b0c1e15463a4adb8405578c540f36d4", Size: 6} + + contentStore := lfs.NewContentStore() + exist, err := contentStore.Exists(p) + require.NoError(t, err) + assert.False(t, exist) + err = contentStore.Put(p, bytes.NewReader([]byte("dummy5"))) + require.NoError(t, err) + + meta, err := git_model.GetLFSMetaObjectByOid(db.DefaultContext, repo.ID, p.Oid) + assert.Nil(t, meta) + assert.Equal(t, git_model.ErrLFSObjectNotExist, err) + + t.Run("InvalidAccess", func(t *testing.T) { + req := newRequest(t, p, "invalid") + session.MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + + t.Run("ValidAccess", func(t *testing.T) { + req := newRequest(t, p, "dummy5") + + session.MakeRequest(t, req, http.StatusOK) + meta, err = git_model.GetLFSMetaObjectByOid(db.DefaultContext, repo.ID, p.Oid) + require.NoError(t, err) + assert.NotNil(t, meta) + }) + + // Cleanup + err = contentStore.Delete(p.RelativePath()) + require.NoError(t, err) + }) + + t.Run("MetaAlreadyExists", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := newRequest(t, lfs.Pointer{Oid: oid, Size: 6}, "") + + session.MakeRequest(t, req, http.StatusOK) + }) + + t.Run("HashMismatch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := newRequest(t, lfs.Pointer{Oid: "2581dd7bbc1fe44726de4b7dd806a087a978b9c5aec0a60481259e34be09b06a", Size: 1}, "a") + + session.MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + + t.Run("SizeMismatch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := newRequest(t, lfs.Pointer{Oid: "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", Size: 2}, "a") + + session.MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + + t.Run("Success", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + p := lfs.Pointer{Oid: "6ccce4863b70f258d691f59609d31b4502e1ba5199942d3bc5d35d17a4ce771d", Size: 5} + + req := newRequest(t, p, "gitea") + + session.MakeRequest(t, req, http.StatusOK) + + contentStore := lfs.NewContentStore() + exist, err := contentStore.Exists(p) + require.NoError(t, err) + assert.True(t, exist) + + meta, err := git_model.GetLFSMetaObjectByOid(db.DefaultContext, repo.ID, p.Oid) + require.NoError(t, err) + assert.NotNil(t, meta) + }) +} + +func TestAPILFSVerify(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + setting.LFS.StartServer = true + + repo := createLFSTestRepository(t, "verify") + + content := []byte("dummy3") + oid := storeObjectInRepo(t, repo.ID, &content) + defer git_model.RemoveLFSMetaObjectByOid(db.DefaultContext, repo.ID, oid) + + session := loginUser(t, "user2") + + newRequest := func(t testing.TB, p *lfs.Pointer) *RequestWrapper { + return NewRequestWithJSON(t, "POST", "/user2/lfs-verify-repo.git/info/lfs/verify", p). + SetHeader("Accept", lfs.AcceptHeader). + SetHeader("Content-Type", lfs.MediaType) + } + + t.Run("InvalidJsonRequest", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := newRequest(t, nil) + + session.MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + + t.Run("InvalidPointer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := newRequest(t, &lfs.Pointer{}) + + session.MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + + t.Run("PointerNotExisting", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := newRequest(t, &lfs.Pointer{Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab042", Size: 6}) + + session.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("Success", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := newRequest(t, &lfs.Pointer{Oid: oid, Size: 6}) + + session.MakeRequest(t, req, http.StatusOK) + }) +} diff --git a/tests/integration/api_repo_raw_test.go b/tests/integration/api_repo_raw_test.go new file mode 100644 index 0000000..e5f83d1 --- /dev/null +++ b/tests/integration/api_repo_raw_test.go @@ -0,0 +1,40 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIReposRaw(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + // Login as User2. + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + for _, ref := range [...]string{ + "master", // Branch + "v1.1", // Tag + "65f1bf27bc3bf70f64657658635e66094edbcb4d", // Commit + } { + req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/raw/%s/README.md", user.Name, ref). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + assert.EqualValues(t, "file", resp.Header().Get("x-gitea-object-type")) + } + // Test default branch + req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/raw/README.md", user.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + assert.EqualValues(t, "file", resp.Header().Get("x-gitea-object-type")) +} diff --git a/tests/integration/api_repo_secrets_test.go b/tests/integration/api_repo_secrets_test.go new file mode 100644 index 0000000..c3074d9 --- /dev/null +++ b/tests/integration/api_repo_secrets_test.go @@ -0,0 +1,112 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" +) + +func TestAPIRepoSecrets(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + t.Run("List", func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/secrets", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("Create", func(t *testing.T) { + cases := []struct { + Name string + ExpectedStatus int + }{ + { + Name: "", + ExpectedStatus: http.StatusMethodNotAllowed, + }, + { + Name: "-", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "_", + ExpectedStatus: http.StatusCreated, + }, + { + Name: "secret", + ExpectedStatus: http.StatusCreated, + }, + { + Name: "2secret", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "GITEA_secret", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "GITHUB_secret", + ExpectedStatus: http.StatusBadRequest, + }, + } + + for _, c := range cases { + req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/actions/secrets/%s", repo.FullName(), c.Name), api.CreateOrUpdateSecretOption{ + Data: "data", + }).AddTokenAuth(token) + MakeRequest(t, req, c.ExpectedStatus) + } + }) + + t.Run("Update", func(t *testing.T) { + name := "update_secret" + url := fmt.Sprintf("/api/v1/repos/%s/actions/secrets/%s", repo.FullName(), name) + + req := NewRequestWithJSON(t, "PUT", url, api.CreateOrUpdateSecretOption{ + Data: "initial", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequestWithJSON(t, "PUT", url, api.CreateOrUpdateSecretOption{ + Data: "changed", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + }) + + t.Run("Delete", func(t *testing.T) { + name := "delete_secret" + url := fmt.Sprintf("/api/v1/repos/%s/actions/secrets/%s", repo.FullName(), name) + + req := NewRequestWithJSON(t, "PUT", url, api.CreateOrUpdateSecretOption{ + Data: "initial", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequest(t, "DELETE", url). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "DELETE", url). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/actions/secrets/000", repo.FullName())). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusBadRequest) + }) +} diff --git a/tests/integration/api_repo_tags_test.go b/tests/integration/api_repo_tags_test.go new file mode 100644 index 0000000..09f17ef --- /dev/null +++ b/tests/integration/api_repo_tags_test.go @@ -0,0 +1,123 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIRepoTags(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + // Login as User2. + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + repoName := "repo1" + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/tags", user.Name, repoName). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var tags []*api.Tag + DecodeJSON(t, resp, &tags) + + assert.Len(t, tags, 1) + assert.Equal(t, "v1.1", tags[0].Name) + assert.Equal(t, "Initial commit", tags[0].Message) + assert.Equal(t, "65f1bf27bc3bf70f64657658635e66094edbcb4d", tags[0].Commit.SHA) + assert.Equal(t, setting.AppURL+"api/v1/repos/user2/repo1/git/commits/65f1bf27bc3bf70f64657658635e66094edbcb4d", tags[0].Commit.URL) + assert.Equal(t, setting.AppURL+"user2/repo1/archive/v1.1.zip", tags[0].ZipballURL) + assert.Equal(t, setting.AppURL+"user2/repo1/archive/v1.1.tar.gz", tags[0].TarballURL) + + newTag := createNewTagUsingAPI(t, token, user.Name, repoName, "gitea/22", "", "nice!\nand some text") + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &tags) + assert.Len(t, tags, 2) + for _, tag := range tags { + if tag.Name != "v1.1" { + assert.EqualValues(t, newTag.Name, tag.Name) + assert.EqualValues(t, newTag.Message, tag.Message) + assert.EqualValues(t, "nice!\nand some text", tag.Message) + assert.EqualValues(t, newTag.Commit.SHA, tag.Commit.SHA) + } + } + + // get created tag + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/tags/%s", user.Name, repoName, newTag.Name). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var tag *api.Tag + DecodeJSON(t, resp, &tag) + assert.EqualValues(t, newTag, tag) + + // delete tag + delReq := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/tags/%s", user.Name, repoName, newTag.Name). + AddTokenAuth(token) + MakeRequest(t, delReq, http.StatusNoContent) + + // check if it's gone + MakeRequest(t, req, http.StatusNotFound) +} + +func createNewTagUsingAPI(t *testing.T, token, ownerName, repoName, name, target, msg string) *api.Tag { + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/tags", ownerName, repoName) + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateTagOption{ + TagName: name, + Message: msg, + Target: target, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var respObj api.Tag + DecodeJSON(t, resp, &respObj) + return &respObj +} + +func TestAPIGetTagArchiveDownloadCount(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + // Login as User2. + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + repoName := "repo1" + tagName := "TagDownloadCount" + + createNewTagUsingAPI(t, token, user.Name, repoName, tagName, "", "") + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/tags/%s?token=%s", user.Name, repoName, tagName, token) + + req := NewRequest(t, "GET", urlStr) + resp := MakeRequest(t, req, http.StatusOK) + + var tagInfo *api.Tag + DecodeJSON(t, resp, &tagInfo) + + // Check if everything defaults to 0 + assert.Equal(t, int64(0), tagInfo.ArchiveDownloadCount.TarGz) + assert.Equal(t, int64(0), tagInfo.ArchiveDownloadCount.Zip) + + // Download the tarball to increase the count + MakeRequest(t, NewRequest(t, "GET", tagInfo.TarballURL), http.StatusOK) + + // Check if the count has increased + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &tagInfo) + + assert.Equal(t, int64(1), tagInfo.ArchiveDownloadCount.TarGz) + assert.Equal(t, int64(0), tagInfo.ArchiveDownloadCount.Zip) +} diff --git a/tests/integration/api_repo_teams_test.go b/tests/integration/api_repo_teams_test.go new file mode 100644 index 0000000..91bfd66 --- /dev/null +++ b/tests/integration/api_repo_teams_test.go @@ -0,0 +1,82 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIRepoTeams(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // publicOrgRepo = org3/repo21 + publicOrgRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 32}) + // user4 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // ListTeams + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/teams", publicOrgRepo.FullName())). + AddTokenAuth(token) + res := MakeRequest(t, req, http.StatusOK) + var teams []*api.Team + DecodeJSON(t, res, &teams) + if assert.Len(t, teams, 2) { + assert.EqualValues(t, "Owners", teams[0].Name) + assert.True(t, teams[0].CanCreateOrgRepo) + assert.True(t, util.SliceSortedEqual(unit.AllUnitKeyNames(), teams[0].Units)) + assert.EqualValues(t, "owner", teams[0].Permission) + + assert.EqualValues(t, "test_team", teams[1].Name) + assert.False(t, teams[1].CanCreateOrgRepo) + assert.EqualValues(t, []string{"repo.issues"}, teams[1].Units) + assert.EqualValues(t, "write", teams[1].Permission) + } + + // IsTeam + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/teams/%s", publicOrgRepo.FullName(), "Test_Team")). + AddTokenAuth(token) + res = MakeRequest(t, req, http.StatusOK) + var team *api.Team + DecodeJSON(t, res, &team) + assert.EqualValues(t, teams[1], team) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/teams/%s", publicOrgRepo.FullName(), "NonExistingTeam")). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + // AddTeam with user4 + req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/teams/%s", publicOrgRepo.FullName(), "team1")). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + // AddTeam with user2 + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session = loginUser(t, user.Name) + token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/teams/%s", publicOrgRepo.FullName(), "team1")). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + MakeRequest(t, req, http.StatusUnprocessableEntity) // test duplicate request + + // DeleteTeam + req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/teams/%s", publicOrgRepo.FullName(), "team1")). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + MakeRequest(t, req, http.StatusUnprocessableEntity) // test duplicate request +} diff --git a/tests/integration/api_repo_test.go b/tests/integration/api_repo_test.go new file mode 100644 index 0000000..b635b70 --- /dev/null +++ b/tests/integration/api_repo_test.go @@ -0,0 +1,766 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + repo_service "code.gitea.io/gitea/services/repository" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIUserReposNotLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + req := NewRequestf(t, "GET", "/api/v1/users/%s/repos", user.Name) + resp := MakeRequest(t, req, http.StatusOK) + + var apiRepos []api.Repository + DecodeJSON(t, resp, &apiRepos) + expectedLen := unittest.GetCount(t, repo_model.Repository{OwnerID: user.ID}, + unittest.Cond("is_private = ?", false)) + assert.Len(t, apiRepos, expectedLen) + for _, repo := range apiRepos { + assert.EqualValues(t, user.ID, repo.Owner.ID) + assert.False(t, repo.Private) + } +} + +func TestAPIUserReposWithWrongToken(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + wrongToken := fmt.Sprintf("Bearer %s", "wrong_token") + req := NewRequestf(t, "GET", "/api/v1/users/%s/repos", user.Name). + AddTokenAuth(wrongToken) + resp := MakeRequest(t, req, http.StatusUnauthorized) + + assert.Contains(t, resp.Body.String(), "user does not exist") +} + +func TestAPISearchRepo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + const keyword = "test" + + req := NewRequestf(t, "GET", "/api/v1/repos/search?q=%s", keyword) + resp := MakeRequest(t, req, http.StatusOK) + + var body api.SearchResults + DecodeJSON(t, resp, &body) + assert.NotEmpty(t, body.Data) + for _, repo := range body.Data { + assert.Contains(t, repo.Name, keyword) + assert.False(t, repo.Private) + } + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 16}) + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 18}) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 20}) + orgUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17}) + + oldAPIDefaultNum := setting.API.DefaultPagingNum + defer func() { + setting.API.DefaultPagingNum = oldAPIDefaultNum + }() + setting.API.DefaultPagingNum = 10 + + // Map of expected results, where key is user for login + type expectedResults map[*user_model.User]struct { + count int + repoOwnerID int64 + repoName string + includesPrivate bool + } + + testCases := []struct { + name, requestURL string + expectedResults + }{ + { + name: "RepositoriesMax50", requestURL: "/api/v1/repos/search?limit=50&private=false", expectedResults: expectedResults{ + nil: {count: 37}, + user: {count: 37}, + user2: {count: 37}, + }, + }, + { + name: "RepositoriesMax10", requestURL: "/api/v1/repos/search?limit=10&private=false", expectedResults: expectedResults{ + nil: {count: 10}, + user: {count: 10}, + user2: {count: 10}, + }, + }, + { + name: "RepositoriesDefault", requestURL: "/api/v1/repos/search?default&private=false", expectedResults: expectedResults{ + nil: {count: 10}, + user: {count: 10}, + user2: {count: 10}, + }, + }, + { + name: "RepositoriesByName", requestURL: fmt.Sprintf("/api/v1/repos/search?q=%s&private=false", "big_test_"), expectedResults: expectedResults{ + nil: {count: 7, repoName: "big_test_"}, + user: {count: 7, repoName: "big_test_"}, + user2: {count: 7, repoName: "big_test_"}, + }, + }, + { + name: "RepositoriesByName", requestURL: fmt.Sprintf("/api/v1/repos/search?q=%s&private=false", "user2/big_test_"), expectedResults: expectedResults{ + user2: {count: 2, repoName: "big_test_"}, + }, + }, + { + name: "RepositoriesAccessibleAndRelatedToUser", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d", user.ID), expectedResults: expectedResults{ + nil: {count: 5}, + user: {count: 9, includesPrivate: true}, + user2: {count: 6, includesPrivate: true}, + }, + }, + { + name: "RepositoriesAccessibleAndRelatedToUser2", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d", user2.ID), expectedResults: expectedResults{ + nil: {count: 1}, + user: {count: 2, includesPrivate: true}, + user2: {count: 2, includesPrivate: true}, + user4: {count: 1}, + }, + }, + { + name: "RepositoriesAccessibleAndRelatedToUser3", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d", org3.ID), expectedResults: expectedResults{ + nil: {count: 1}, + user: {count: 4, includesPrivate: true}, + user2: {count: 3, includesPrivate: true}, + org3: {count: 4, includesPrivate: true}, + }, + }, + { + name: "RepositoriesOwnedByOrganization", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d", orgUser.ID), expectedResults: expectedResults{ + nil: {count: 1, repoOwnerID: orgUser.ID}, + user: {count: 2, repoOwnerID: orgUser.ID, includesPrivate: true}, + user2: {count: 1, repoOwnerID: orgUser.ID}, + }, + }, + {name: "RepositoriesAccessibleAndRelatedToUser4", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d", user4.ID), expectedResults: expectedResults{ + nil: {count: 3}, + user: {count: 4, includesPrivate: true}, + user4: {count: 7, includesPrivate: true}, + }}, + {name: "RepositoriesAccessibleAndRelatedToUser4/SearchModeSource", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d&mode=%s", user4.ID, "source"), expectedResults: expectedResults{ + nil: {count: 0}, + user: {count: 1, includesPrivate: true}, + user4: {count: 1, includesPrivate: true}, + }}, + {name: "RepositoriesAccessibleAndRelatedToUser4/SearchModeFork", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d&mode=%s", user4.ID, "fork"), expectedResults: expectedResults{ + nil: {count: 1}, + user: {count: 1}, + user4: {count: 2, includesPrivate: true}, + }}, + {name: "RepositoriesAccessibleAndRelatedToUser4/SearchModeFork/Exclusive", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d&mode=%s&exclusive=1", user4.ID, "fork"), expectedResults: expectedResults{ + nil: {count: 1}, + user: {count: 1}, + user4: {count: 2, includesPrivate: true}, + }}, + {name: "RepositoriesAccessibleAndRelatedToUser4/SearchModeMirror", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d&mode=%s", user4.ID, "mirror"), expectedResults: expectedResults{ + nil: {count: 2}, + user: {count: 2}, + user4: {count: 4, includesPrivate: true}, + }}, + {name: "RepositoriesAccessibleAndRelatedToUser4/SearchModeMirror/Exclusive", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d&mode=%s&exclusive=1", user4.ID, "mirror"), expectedResults: expectedResults{ + nil: {count: 1}, + user: {count: 1}, + user4: {count: 2, includesPrivate: true}, + }}, + {name: "RepositoriesAccessibleAndRelatedToUser4/SearchModeCollaborative", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d&mode=%s", user4.ID, "collaborative"), expectedResults: expectedResults{ + nil: {count: 0}, + user: {count: 1, includesPrivate: true}, + user4: {count: 1, includesPrivate: true}, + }}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + for userToLogin, expected := range testCase.expectedResults { + var testName string + var userID int64 + var token string + if userToLogin != nil && userToLogin.ID > 0 { + testName = fmt.Sprintf("LoggedUser%d", userToLogin.ID) + session := loginUser(t, userToLogin.Name) + token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + userID = userToLogin.ID + } else { + testName = "AnonymousUser" + _ = emptyTestSession(t) + } + + t.Run(testName, func(t *testing.T) { + request := NewRequest(t, "GET", testCase.requestURL). + AddTokenAuth(token) + response := MakeRequest(t, request, http.StatusOK) + + var body api.SearchResults + DecodeJSON(t, response, &body) + + repoNames := make([]string, 0, len(body.Data)) + for _, repo := range body.Data { + repoNames = append(repoNames, fmt.Sprintf("%d:%s:%t", repo.ID, repo.FullName, repo.Private)) + } + assert.Len(t, repoNames, expected.count) + for _, repo := range body.Data { + r := getRepo(t, repo.ID) + hasAccess, err := access_model.HasAccess(db.DefaultContext, userID, r) + require.NoError(t, err, "Error when checking if User: %d has access to %s: %v", userID, repo.FullName, err) + assert.True(t, hasAccess, "User: %d does not have access to %s", userID, repo.FullName) + + assert.NotEmpty(t, repo.Name) + assert.Equal(t, repo.Name, r.Name) + + if len(expected.repoName) > 0 { + assert.Contains(t, repo.Name, expected.repoName) + } + + if expected.repoOwnerID > 0 { + assert.Equal(t, expected.repoOwnerID, repo.Owner.ID) + } + + if !expected.includesPrivate { + assert.False(t, repo.Private, "User: %d not expecting private repository: %s", userID, repo.FullName) + } + } + }) + } + }) + } +} + +var repoCache = make(map[int64]*repo_model.Repository) + +func getRepo(t *testing.T, repoID int64) *repo_model.Repository { + if _, ok := repoCache[repoID]; !ok { + repoCache[repoID] = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) + } + return repoCache[repoID] +} + +func TestAPIViewRepo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + var repo api.Repository + + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1") + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repo) + assert.EqualValues(t, 1, repo.ID) + assert.EqualValues(t, "repo1", repo.Name) + assert.EqualValues(t, 2, repo.Releases) + assert.EqualValues(t, 1, repo.OpenIssues) + assert.EqualValues(t, 3, repo.OpenPulls) + + req = NewRequest(t, "GET", "/api/v1/repos/user12/repo10") + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repo) + assert.EqualValues(t, 10, repo.ID) + assert.EqualValues(t, "repo10", repo.Name) + assert.EqualValues(t, 1, repo.OpenPulls) + assert.EqualValues(t, 1, repo.Forks) + + req = NewRequest(t, "GET", "/api/v1/repos/user5/repo4") + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repo) + assert.EqualValues(t, 4, repo.ID) + assert.EqualValues(t, "repo4", repo.Name) + assert.EqualValues(t, 1, repo.Stars) +} + +func TestAPIOrgRepos(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + // org3 is an Org. Check their repos. + sourceOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + + expectedResults := map[*user_model.User]struct { + count int + includesPrivate bool + }{ + user: {count: 1}, + user: {count: 3, includesPrivate: true}, + user2: {count: 3, includesPrivate: true}, + org3: {count: 1}, + } + + for userToLogin, expected := range expectedResults { + testName := fmt.Sprintf("LoggedUser%d", userToLogin.ID) + session := loginUser(t, userToLogin.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization) + + t.Run(testName, func(t *testing.T) { + req := NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", sourceOrg.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var apiRepos []*api.Repository + DecodeJSON(t, resp, &apiRepos) + assert.Len(t, apiRepos, expected.count) + for _, repo := range apiRepos { + if !expected.includesPrivate { + assert.False(t, repo.Private) + } + } + }) + } +} + +// See issue #28483. Tests to make sure we consider more than just code unit-enabled repositories. +func TestAPIOrgReposWithCodeUnitDisabled(t *testing.T) { + defer tests.PrepareTestEnv(t)() + repo21 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo21"}) + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo21.OwnerID}) + + // Disable code repository unit. + var units []unit_model.Type + units = append(units, unit_model.TypeCode) + + if err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo21, nil, units); err != nil { + assert.Fail(t, "should have been able to delete code repository unit; failed to %v", err) + } + assert.False(t, repo21.UnitEnabled(db.DefaultContext, unit_model.TypeCode)) + + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization) + + req := NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", org3.Name). + AddTokenAuth(token) + + resp := MakeRequest(t, req, http.StatusOK) + var apiRepos []*api.Repository + DecodeJSON(t, resp, &apiRepos) + + var repoNames []string + for _, r := range apiRepos { + repoNames = append(repoNames, r.Name) + } + + assert.Contains(t, repoNames, repo21.Name) +} + +func TestAPIGetRepoByIDUnauthorized(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + req := NewRequest(t, "GET", "/api/v1/repositories/2"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestAPIRepoMigrate(t *testing.T) { + testCases := []struct { + ctxUserID, userID int64 + cloneURL, repoName string + expectedStatus int + }{ + {ctxUserID: 1, userID: 2, cloneURL: "https://github.com/go-gitea/test_repo.git", repoName: "git-admin", expectedStatus: http.StatusCreated}, + {ctxUserID: 2, userID: 2, cloneURL: "https://github.com/go-gitea/test_repo.git", repoName: "git-own", expectedStatus: http.StatusCreated}, + {ctxUserID: 2, userID: 1, cloneURL: "https://github.com/go-gitea/test_repo.git", repoName: "git-bad", expectedStatus: http.StatusForbidden}, + {ctxUserID: 2, userID: 3, cloneURL: "https://github.com/go-gitea/test_repo.git", repoName: "git-org", expectedStatus: http.StatusCreated}, + {ctxUserID: 2, userID: 6, cloneURL: "https://github.com/go-gitea/test_repo.git", repoName: "git-bad-org", expectedStatus: http.StatusForbidden}, + {ctxUserID: 2, userID: 3, cloneURL: "https://localhost:3000/user/test_repo.git", repoName: "private-ip", expectedStatus: http.StatusUnprocessableEntity}, + {ctxUserID: 2, userID: 3, cloneURL: "https://10.0.0.1/user/test_repo.git", repoName: "private-ip", expectedStatus: http.StatusUnprocessableEntity}, + } + + defer tests.PrepareTestEnv(t)() + for _, testCase := range testCases { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: testCase.ctxUserID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/migrate", &api.MigrateRepoOptions{ + CloneAddr: testCase.cloneURL, + RepoOwnerID: testCase.userID, + RepoName: testCase.repoName, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, NoExpectedStatus) + if resp.Code == http.StatusUnprocessableEntity { + respJSON := map[string]string{} + DecodeJSON(t, resp, &respJSON) + switch respJSON["message"] { + case "Remote visit addressed rate limitation.": + t.Log("test hit github rate limitation") + case "You can not import from disallowed hosts.": + assert.EqualValues(t, "private-ip", testCase.repoName) + default: + assert.FailNow(t, "unexpected error '%v' on url '%s'", respJSON["message"], testCase.cloneURL) + } + } else { + assert.EqualValues(t, testCase.expectedStatus, resp.Code) + } + } +} + +func TestAPIRepoMigrateConflict(t *testing.T) { + onGiteaRun(t, testAPIRepoMigrateConflict) +} + +func testAPIRepoMigrateConflict(t *testing.T, u *url.URL) { + username := "user2" + baseAPITestContext := NewAPITestContext(t, username, "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + u.Path = baseAPITestContext.GitPath() + + t.Run("Existing", func(t *testing.T) { + httpContext := baseAPITestContext + + httpContext.Reponame = "repo-tmp-17" + t.Run("CreateRepo", doAPICreateRepository(httpContext, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat + + user, err := user_model.GetUserByName(db.DefaultContext, httpContext.Username) + require.NoError(t, err) + userID := user.ID + + cloneURL := "https://github.com/go-gitea/test_repo.git" + + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/migrate", + &api.MigrateRepoOptions{ + CloneAddr: cloneURL, + RepoOwnerID: userID, + RepoName: httpContext.Reponame, + }). + AddTokenAuth(httpContext.Token) + resp := httpContext.Session.MakeRequest(t, req, http.StatusConflict) + respJSON := map[string]string{} + DecodeJSON(t, resp, &respJSON) + assert.Equal(t, "The repository with the same name already exists.", respJSON["message"]) + }) +} + +// mirror-sync must fail with "400 (Bad Request)" when an attempt is made to +// sync a non-mirror repository. +func TestAPIMirrorSyncNonMirrorRepo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + var repo api.Repository + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1") + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repo) + assert.False(t, repo.Mirror) + + req = NewRequestf(t, "POST", "/api/v1/repos/user2/repo1/mirror-sync"). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusBadRequest) + errRespJSON := map[string]string{} + DecodeJSON(t, resp, &errRespJSON) + assert.Equal(t, "Repository is not a mirror", errRespJSON["message"]) +} + +func TestAPIOrgRepoCreate(t *testing.T) { + testCases := []struct { + ctxUserID int64 + orgName, repoName string + expectedStatus int + }{ + {ctxUserID: 1, orgName: "org3", repoName: "repo-admin", expectedStatus: http.StatusCreated}, + {ctxUserID: 2, orgName: "org3", repoName: "repo-own", expectedStatus: http.StatusCreated}, + {ctxUserID: 2, orgName: "org6", repoName: "repo-bad-org", expectedStatus: http.StatusForbidden}, + {ctxUserID: 28, orgName: "org3", repoName: "repo-creator", expectedStatus: http.StatusCreated}, + {ctxUserID: 28, orgName: "org6", repoName: "repo-not-creator", expectedStatus: http.StatusForbidden}, + } + + defer tests.PrepareTestEnv(t)() + for _, testCase := range testCases { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: testCase.ctxUserID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/org/%s/repos", testCase.orgName), &api.CreateRepoOption{ + Name: testCase.repoName, + }).AddTokenAuth(token) + MakeRequest(t, req, testCase.expectedStatus) + } +} + +func TestAPIRepoCreateConflict(t *testing.T) { + onGiteaRun(t, testAPIRepoCreateConflict) +} + +func testAPIRepoCreateConflict(t *testing.T, u *url.URL) { + username := "user2" + baseAPITestContext := NewAPITestContext(t, username, "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + u.Path = baseAPITestContext.GitPath() + + t.Run("Existing", func(t *testing.T) { + httpContext := baseAPITestContext + + httpContext.Reponame = "repo-tmp-17" + t.Run("CreateRepo", doAPICreateRepository(httpContext, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat + + req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", + &api.CreateRepoOption{ + Name: httpContext.Reponame, + }). + AddTokenAuth(httpContext.Token) + resp := httpContext.Session.MakeRequest(t, req, http.StatusConflict) + respJSON := map[string]string{} + DecodeJSON(t, resp, &respJSON) + assert.Equal(t, "The repository with the same name already exists.", respJSON["message"]) + }) +} + +func TestAPIRepoTransfer(t *testing.T) { + testCases := []struct { + ctxUserID int64 + newOwner string + teams *[]int64 + expectedStatus int + }{ + // Disclaimer for test story: "user1" is an admin, "user2" is normal user and part of in owner team of org "org3" + // Transfer to a user with teams in another org should fail + {ctxUserID: 1, newOwner: "org3", teams: &[]int64{5}, expectedStatus: http.StatusForbidden}, + // Transfer to a user with non-existent team IDs should fail + {ctxUserID: 1, newOwner: "user2", teams: &[]int64{2}, expectedStatus: http.StatusUnprocessableEntity}, + // Transfer should go through + {ctxUserID: 1, newOwner: "org3", teams: &[]int64{2}, expectedStatus: http.StatusAccepted}, + // Let user transfer it back to himself + {ctxUserID: 2, newOwner: "user2", expectedStatus: http.StatusAccepted}, + // And revert transfer + {ctxUserID: 2, newOwner: "org3", teams: &[]int64{2}, expectedStatus: http.StatusAccepted}, + // Cannot start transfer to an existing repo + {ctxUserID: 2, newOwner: "org3", teams: nil, expectedStatus: http.StatusUnprocessableEntity}, + // Start transfer, repo is now in pending transfer mode + {ctxUserID: 2, newOwner: "org6", teams: nil, expectedStatus: http.StatusCreated}, + } + + defer tests.PrepareTestEnv(t)() + + // create repo to move + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + repoName := "moveME" + apiRepo := new(api.Repository) + req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{ + Name: repoName, + Description: "repo move around", + Private: false, + Readme: "Default", + AutoInit: true, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, apiRepo) + + // start testing + for _, testCase := range testCases { + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: testCase.ctxUserID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}) + session = loginUser(t, user.Name) + token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer", repo.OwnerName, repo.Name), &api.TransferRepoOption{ + NewOwner: testCase.newOwner, + TeamIDs: testCase.teams, + }).AddTokenAuth(token) + MakeRequest(t, req, testCase.expectedStatus) + } + + // cleanup + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}) + _ = repo_service.DeleteRepositoryDirectly(db.DefaultContext, user, repo.ID) +} + +func transfer(t *testing.T) *repo_model.Repository { + // create repo to move + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + repoName := "moveME" + apiRepo := new(api.Repository) + req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{ + Name: repoName, + Description: "repo move around", + Private: false, + Readme: "Default", + AutoInit: true, + }).AddTokenAuth(token) + + resp := MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, apiRepo) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer", repo.OwnerName, repo.Name), &api.TransferRepoOption{ + NewOwner: "user4", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + return repo +} + +func TestAPIAcceptTransfer(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := transfer(t) + + // try to accept with not authorized user + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject", repo.OwnerName, repo.Name)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + // try to accept repo that's not marked as transferred + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/accept", "user2", "repo1")). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + // accept transfer + session = loginUser(t, "user4") + token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/accept", repo.OwnerName, repo.Name)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusAccepted) + apiRepo := new(api.Repository) + DecodeJSON(t, resp, apiRepo) + assert.Equal(t, "user4", apiRepo.Owner.UserName) +} + +func TestAPIRejectTransfer(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := transfer(t) + + // try to reject with not authorized user + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject", repo.OwnerName, repo.Name)). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + // try to reject repo that's not marked as transferred + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject", "user2", "repo1")). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + // reject transfer + session = loginUser(t, "user4") + token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject", repo.OwnerName, repo.Name)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + apiRepo := new(api.Repository) + DecodeJSON(t, resp, apiRepo) + assert.Equal(t, "user2", apiRepo.Owner.UserName) +} + +func TestAPIGenerateRepo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + templateRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 44}) + + // user + repo := new(api.Repository) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/generate", templateRepo.OwnerName, templateRepo.Name), &api.GenerateRepoOption{ + Owner: user.Name, + Name: "new-repo", + Description: "test generate repo", + Private: false, + GitContent: true, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, repo) + + assert.Equal(t, "new-repo", repo.Name) + + // org + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/generate", templateRepo.OwnerName, templateRepo.Name), &api.GenerateRepoOption{ + Owner: "org3", + Name: "new-repo", + Description: "test generate repo", + Private: false, + GitContent: true, + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, repo) + + assert.Equal(t, "new-repo", repo.Name) +} + +func TestAPIRepoGetReviewers(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/reviewers", user.Name, repo.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var reviewers []*api.User + DecodeJSON(t, resp, &reviewers) + if assert.Len(t, reviewers, 3) { + assert.ElementsMatch(t, []int64{1, 4, 11}, []int64{reviewers[0].ID, reviewers[1].ID, reviewers[2].ID}) + } +} + +func TestAPIRepoGetAssignees(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/assignees", user.Name, repo.Name). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var assignees []*api.User + DecodeJSON(t, resp, &assignees) + assert.Len(t, assignees, 1) +} + +func TestAPIViewRepoObjectFormat(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + var repo api.Repository + + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1") + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &repo) + assert.EqualValues(t, "sha1", repo.ObjectFormatName) +} + +func TestAPIRepoCommitPull(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + var pr api.PullRequest + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/1a8823cd1a9549fde083f992f6b9b87a7ab74fb3/pull") + resp := MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &pr) + assert.EqualValues(t, 1, pr.ID) + + req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/not-a-commit/pull") + MakeRequest(t, req, http.StatusNotFound) +} diff --git a/tests/integration/api_repo_topic_test.go b/tests/integration/api_repo_topic_test.go new file mode 100644 index 0000000..dcb8ae0 --- /dev/null +++ b/tests/integration/api_repo_topic_test.go @@ -0,0 +1,194 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPITopicSearchPaging(t *testing.T) { + defer tests.PrepareTestEnv(t)() + var topics struct { + TopicNames []*api.TopicResponse `json:"topics"` + } + + // Add 20 unique topics to user2/repo2, and 20 unique ones to user2/repo3 + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + token2 := getUserToken(t, user2.Name, auth_model.AccessTokenScopeWriteRepository) + repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + for i := 0; i < 20; i++ { + req := NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/paging-topic-%d", user2.Name, repo2.Name, i). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusNoContent) + req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/paging-topic-%d", user2.Name, repo3.Name, i+30). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusNoContent) + } + + res := MakeRequest(t, NewRequest(t, "GET", "/api/v1/topics/search"), http.StatusOK) + DecodeJSON(t, res, &topics) + assert.Len(t, topics.TopicNames, 30) + + res = MakeRequest(t, NewRequest(t, "GET", "/api/v1/topics/search?page=2"), http.StatusOK) + DecodeJSON(t, res, &topics) + assert.NotEmpty(t, topics.TopicNames) +} + +func TestAPITopicSearch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + searchURL, _ := url.Parse("/api/v1/topics/search") + var topics struct { + TopicNames []*api.TopicResponse `json:"topics"` + } + + query := url.Values{"page": []string{"1"}, "limit": []string{"4"}} + + searchURL.RawQuery = query.Encode() + res := MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) + DecodeJSON(t, res, &topics) + assert.Len(t, topics.TopicNames, 4) + assert.EqualValues(t, "6", res.Header().Get("x-total-count")) + + query.Add("q", "topic") + searchURL.RawQuery = query.Encode() + res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) + DecodeJSON(t, res, &topics) + assert.Len(t, topics.TopicNames, 2) + + query.Set("q", "database") + searchURL.RawQuery = query.Encode() + res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) + DecodeJSON(t, res, &topics) + if assert.Len(t, topics.TopicNames, 1) { + assert.EqualValues(t, 2, topics.TopicNames[0].ID) + assert.EqualValues(t, "database", topics.TopicNames[0].Name) + assert.EqualValues(t, 1, topics.TopicNames[0].RepoCount) + } +} + +func TestAPIRepoTopic(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of repo2 + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of repo3 + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // write access to repo 3 + repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) + + // Get user2's token + token2 := getUserToken(t, user2.Name, auth_model.AccessTokenScopeWriteRepository) + + // Test read topics using login + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/topics", user2.Name, repo2.Name)). + AddTokenAuth(token2) + res := MakeRequest(t, req, http.StatusOK) + var topics *api.TopicName + DecodeJSON(t, res, &topics) + assert.ElementsMatch(t, []string{"topicname1", "topicname2"}, topics.TopicNames) + + // Test delete a topic + req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/topics/%s", user2.Name, repo2.Name, "Topicname1"). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusNoContent) + + // Test add an existing topic + req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s", user2.Name, repo2.Name, "Golang"). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusNoContent) + + // Test add a topic + req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s", user2.Name, repo2.Name, "topicName3"). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusNoContent) + + url := fmt.Sprintf("/api/v1/repos/%s/%s/topics", user2.Name, repo2.Name) + + // Test read topics using token + req = NewRequest(t, "GET", url). + AddTokenAuth(token2) + res = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, res, &topics) + assert.ElementsMatch(t, []string{"topicname2", "golang", "topicname3"}, topics.TopicNames) + + // Test replace topics + newTopics := []string{" windows ", " ", "MAC "} + req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{ + Topics: newTopics, + }).AddTokenAuth(token2) + MakeRequest(t, req, http.StatusNoContent) + req = NewRequest(t, "GET", url). + AddTokenAuth(token2) + res = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, res, &topics) + assert.ElementsMatch(t, []string{"windows", "mac"}, topics.TopicNames) + + // Test replace topics with something invalid + newTopics = []string{"topicname1", "topicname2", "topicname!"} + req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{ + Topics: newTopics, + }).AddTokenAuth(token2) + MakeRequest(t, req, http.StatusUnprocessableEntity) + req = NewRequest(t, "GET", url). + AddTokenAuth(token2) + res = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, res, &topics) + assert.ElementsMatch(t, []string{"windows", "mac"}, topics.TopicNames) + + // Test with some topics multiple times, less than 25 unique + newTopics = []string{"t1", "t2", "t1", "t3", "t4", "t5", "t6", "t7", "t8", "t9", "t10", "t11", "t12", "t13", "t14", "t15", "t16", "17", "t18", "t19", "t20", "t21", "t22", "t23", "t24", "t25"} + req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{ + Topics: newTopics, + }).AddTokenAuth(token2) + MakeRequest(t, req, http.StatusNoContent) + req = NewRequest(t, "GET", url). + AddTokenAuth(token2) + res = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, res, &topics) + assert.Len(t, topics.TopicNames, 25) + + // Test writing more topics than allowed + newTopics = append(newTopics, "t26") + req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{ + Topics: newTopics, + }).AddTokenAuth(token2) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + // Test add a topic when there is already maximum + req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s", user2.Name, repo2.Name, "t26"). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + // Test delete a topic that repo doesn't have + req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/topics/%s", user2.Name, repo2.Name, "Topicname1"). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusNotFound) + + // Get user4's token + token4 := getUserToken(t, user4.Name, auth_model.AccessTokenScopeWriteRepository) + + // Test read topics with write access + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/topics", org3.Name, repo3.Name)). + AddTokenAuth(token4) + res = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, res, &topics) + assert.Empty(t, topics.TopicNames) + + // Test add a topic to repo with write access (requires repo admin access) + req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s", org3.Name, repo3.Name, "topicName"). + AddTokenAuth(token4) + MakeRequest(t, req, http.StatusForbidden) +} diff --git a/tests/integration/api_repo_variables_test.go b/tests/integration/api_repo_variables_test.go new file mode 100644 index 0000000..7847962 --- /dev/null +++ b/tests/integration/api_repo_variables_test.go @@ -0,0 +1,149 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" +) + +func TestAPIRepoVariables(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + t.Run("CreateRepoVariable", func(t *testing.T) { + cases := []struct { + Name string + ExpectedStatus int + }{ + { + Name: "-", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "_", + ExpectedStatus: http.StatusNoContent, + }, + { + Name: "TEST_VAR", + ExpectedStatus: http.StatusNoContent, + }, + { + Name: "test_var", + ExpectedStatus: http.StatusConflict, + }, + { + Name: "ci", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "123var", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "var@test", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "github_var", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "gitea_var", + ExpectedStatus: http.StatusBadRequest, + }, + } + + for _, c := range cases { + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/variables/%s", repo.FullName(), c.Name), api.CreateVariableOption{ + Value: "value", + }).AddTokenAuth(token) + MakeRequest(t, req, c.ExpectedStatus) + } + }) + + t.Run("UpdateRepoVariable", func(t *testing.T) { + variableName := "test_update_var" + url := fmt.Sprintf("/api/v1/repos/%s/actions/variables/%s", repo.FullName(), variableName) + req := NewRequestWithJSON(t, "POST", url, api.CreateVariableOption{ + Value: "initial_val", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + cases := []struct { + Name string + UpdateName string + ExpectedStatus int + }{ + { + Name: "not_found_var", + ExpectedStatus: http.StatusNotFound, + }, + { + Name: variableName, + UpdateName: "1invalid", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: variableName, + UpdateName: "invalid@name", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: variableName, + UpdateName: "ci", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: variableName, + UpdateName: "updated_var_name", + ExpectedStatus: http.StatusNoContent, + }, + { + Name: variableName, + ExpectedStatus: http.StatusNotFound, + }, + { + Name: "updated_var_name", + ExpectedStatus: http.StatusNoContent, + }, + } + + for _, c := range cases { + req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/actions/variables/%s", repo.FullName(), c.Name), api.UpdateVariableOption{ + Name: c.UpdateName, + Value: "updated_val", + }).AddTokenAuth(token) + MakeRequest(t, req, c.ExpectedStatus) + } + }) + + t.Run("DeleteRepoVariable", func(t *testing.T) { + variableName := "test_delete_var" + url := fmt.Sprintf("/api/v1/repos/%s/actions/variables/%s", repo.FullName(), variableName) + + req := NewRequestWithJSON(t, "POST", url, api.CreateVariableOption{ + Value: "initial_val", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "DELETE", url).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "DELETE", url).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) +} diff --git a/tests/integration/api_settings_test.go b/tests/integration/api_settings_test.go new file mode 100644 index 0000000..9881578 --- /dev/null +++ b/tests/integration/api_settings_test.go @@ -0,0 +1,64 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIExposedSettings(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + ui := new(api.GeneralUISettings) + req := NewRequest(t, "GET", "/api/v1/settings/ui") + resp := MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &ui) + assert.Len(t, ui.AllowedReactions, len(setting.UI.Reactions)) + assert.ElementsMatch(t, setting.UI.Reactions, ui.AllowedReactions) + + apiSettings := new(api.GeneralAPISettings) + req = NewRequest(t, "GET", "/api/v1/settings/api") + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &apiSettings) + assert.EqualValues(t, &api.GeneralAPISettings{ + MaxResponseItems: setting.API.MaxResponseItems, + DefaultPagingNum: setting.API.DefaultPagingNum, + DefaultGitTreesPerPage: setting.API.DefaultGitTreesPerPage, + DefaultMaxBlobSize: setting.API.DefaultMaxBlobSize, + }, apiSettings) + + repo := new(api.GeneralRepoSettings) + req = NewRequest(t, "GET", "/api/v1/settings/repository") + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &repo) + assert.EqualValues(t, &api.GeneralRepoSettings{ + MirrorsDisabled: !setting.Mirror.Enabled, + HTTPGitDisabled: setting.Repository.DisableHTTPGit, + MigrationsDisabled: setting.Repository.DisableMigrations, + TimeTrackingDisabled: false, + LFSDisabled: !setting.LFS.StartServer, + }, repo) + + attachment := new(api.GeneralAttachmentSettings) + req = NewRequest(t, "GET", "/api/v1/settings/attachment") + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &attachment) + assert.EqualValues(t, &api.GeneralAttachmentSettings{ + Enabled: setting.Attachment.Enabled, + AllowedTypes: setting.Attachment.AllowedTypes, + MaxFiles: setting.Attachment.MaxFiles, + MaxSize: setting.Attachment.MaxSize, + }, attachment) +} diff --git a/tests/integration/api_team_test.go b/tests/integration/api_team_test.go new file mode 100644 index 0000000..4fee39d --- /dev/null +++ b/tests/integration/api_team_test.go @@ -0,0 +1,321 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "sort" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPITeam(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + teamUser := unittest.AssertExistsAndLoadBean(t, &organization.TeamUser{ID: 1}) + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamUser.TeamID}) + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: teamUser.OrgID}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: teamUser.UID}) + + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization) + req := NewRequestf(t, "GET", "/api/v1/teams/%d", teamUser.TeamID). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var apiTeam api.Team + DecodeJSON(t, resp, &apiTeam) + assert.EqualValues(t, team.ID, apiTeam.ID) + assert.Equal(t, team.Name, apiTeam.Name) + assert.EqualValues(t, convert.ToOrganization(db.DefaultContext, org), apiTeam.Organization) + + // non team member user will not access the teams details + teamUser2 := unittest.AssertExistsAndLoadBean(t, &organization.TeamUser{ID: 3}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: teamUser2.UID}) + + session = loginUser(t, user2.Name) + token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization) + req = NewRequestf(t, "GET", "/api/v1/teams/%d", teamUser.TeamID). + AddTokenAuth(token) + _ = MakeRequest(t, req, http.StatusForbidden) + + req = NewRequestf(t, "GET", "/api/v1/teams/%d", teamUser.TeamID) + _ = MakeRequest(t, req, http.StatusUnauthorized) + + // Get an admin user able to create, update and delete teams. + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + session = loginUser(t, user.Name) + token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization) + + org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 6}) + + // Create team. + teamToCreate := &api.CreateTeamOption{ + Name: "team1", + Description: "team one", + IncludesAllRepositories: true, + Permission: "write", + Units: []string{"repo.code", "repo.issues"}, + } + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", org.Name), teamToCreate). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusCreated) + apiTeam = api.Team{} + DecodeJSON(t, resp, &apiTeam) + checkTeamResponse(t, "CreateTeam1", &apiTeam, teamToCreate.Name, teamToCreate.Description, teamToCreate.IncludesAllRepositories, + teamToCreate.Permission, teamToCreate.Units, nil) + checkTeamBean(t, apiTeam.ID, teamToCreate.Name, teamToCreate.Description, teamToCreate.IncludesAllRepositories, + teamToCreate.Permission, teamToCreate.Units, nil) + teamID := apiTeam.ID + + // Edit team. + editDescription := "team 1" + editFalse := false + teamToEdit := &api.EditTeamOption{ + Name: "teamone", + Description: &editDescription, + Permission: "admin", + IncludesAllRepositories: &editFalse, + Units: []string{"repo.code", "repo.pulls", "repo.releases"}, + } + + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/teams/%d", teamID), teamToEdit). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + apiTeam = api.Team{} + DecodeJSON(t, resp, &apiTeam) + checkTeamResponse(t, "EditTeam1", &apiTeam, teamToEdit.Name, *teamToEdit.Description, *teamToEdit.IncludesAllRepositories, + teamToEdit.Permission, unit.AllUnitKeyNames(), nil) + checkTeamBean(t, apiTeam.ID, teamToEdit.Name, *teamToEdit.Description, *teamToEdit.IncludesAllRepositories, + teamToEdit.Permission, unit.AllUnitKeyNames(), nil) + + // Edit team Description only + editDescription = "first team" + teamToEditDesc := api.EditTeamOption{Description: &editDescription} + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/teams/%d", teamID), teamToEditDesc). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + apiTeam = api.Team{} + DecodeJSON(t, resp, &apiTeam) + checkTeamResponse(t, "EditTeam1_DescOnly", &apiTeam, teamToEdit.Name, *teamToEditDesc.Description, *teamToEdit.IncludesAllRepositories, + teamToEdit.Permission, unit.AllUnitKeyNames(), nil) + checkTeamBean(t, apiTeam.ID, teamToEdit.Name, *teamToEditDesc.Description, *teamToEdit.IncludesAllRepositories, + teamToEdit.Permission, unit.AllUnitKeyNames(), nil) + + // Read team. + teamRead := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) + require.NoError(t, teamRead.LoadUnits(db.DefaultContext)) + req = NewRequestf(t, "GET", "/api/v1/teams/%d", teamID). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + apiTeam = api.Team{} + DecodeJSON(t, resp, &apiTeam) + checkTeamResponse(t, "ReadTeam1", &apiTeam, teamRead.Name, *teamToEditDesc.Description, teamRead.IncludesAllRepositories, + teamRead.AccessMode.String(), teamRead.GetUnitNames(), teamRead.GetUnitsMap()) + + // Delete team. + req = NewRequestf(t, "DELETE", "/api/v1/teams/%d", teamID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + unittest.AssertNotExistsBean(t, &organization.Team{ID: teamID}) + + // create team again via UnitsMap + // Create team. + teamToCreate = &api.CreateTeamOption{ + Name: "team2", + Description: "team two", + IncludesAllRepositories: true, + Permission: "write", + UnitsMap: map[string]string{"repo.code": "read", "repo.issues": "write", "repo.wiki": "none"}, + } + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", org.Name), teamToCreate). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusCreated) + apiTeam = api.Team{} + DecodeJSON(t, resp, &apiTeam) + checkTeamResponse(t, "CreateTeam2", &apiTeam, teamToCreate.Name, teamToCreate.Description, teamToCreate.IncludesAllRepositories, + "read", nil, teamToCreate.UnitsMap) + checkTeamBean(t, apiTeam.ID, teamToCreate.Name, teamToCreate.Description, teamToCreate.IncludesAllRepositories, + "read", nil, teamToCreate.UnitsMap) + teamID = apiTeam.ID + + // Edit team. + editDescription = "team 1" + editFalse = false + teamToEdit = &api.EditTeamOption{ + Name: "teamtwo", + Description: &editDescription, + Permission: "write", + IncludesAllRepositories: &editFalse, + UnitsMap: map[string]string{"repo.code": "read", "repo.pulls": "read", "repo.releases": "write"}, + } + + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/teams/%d", teamID), teamToEdit). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + apiTeam = api.Team{} + DecodeJSON(t, resp, &apiTeam) + checkTeamResponse(t, "EditTeam2", &apiTeam, teamToEdit.Name, *teamToEdit.Description, *teamToEdit.IncludesAllRepositories, + "read", nil, teamToEdit.UnitsMap) + checkTeamBean(t, apiTeam.ID, teamToEdit.Name, *teamToEdit.Description, *teamToEdit.IncludesAllRepositories, + "read", nil, teamToEdit.UnitsMap) + + // Edit team Description only + editDescription = "second team" + teamToEditDesc = api.EditTeamOption{Description: &editDescription} + req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/teams/%d", teamID), teamToEditDesc). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + apiTeam = api.Team{} + DecodeJSON(t, resp, &apiTeam) + checkTeamResponse(t, "EditTeam2_DescOnly", &apiTeam, teamToEdit.Name, *teamToEditDesc.Description, *teamToEdit.IncludesAllRepositories, + "read", nil, teamToEdit.UnitsMap) + checkTeamBean(t, apiTeam.ID, teamToEdit.Name, *teamToEditDesc.Description, *teamToEdit.IncludesAllRepositories, + "read", nil, teamToEdit.UnitsMap) + + // Read team. + teamRead = unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) + req = NewRequestf(t, "GET", "/api/v1/teams/%d", teamID). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + apiTeam = api.Team{} + DecodeJSON(t, resp, &apiTeam) + require.NoError(t, teamRead.LoadUnits(db.DefaultContext)) + checkTeamResponse(t, "ReadTeam2", &apiTeam, teamRead.Name, *teamToEditDesc.Description, teamRead.IncludesAllRepositories, + teamRead.AccessMode.String(), teamRead.GetUnitNames(), teamRead.GetUnitsMap()) + + // Delete team. + req = NewRequestf(t, "DELETE", "/api/v1/teams/%d", teamID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + unittest.AssertNotExistsBean(t, &organization.Team{ID: teamID}) + + // Create admin team + teamToCreate = &api.CreateTeamOption{ + Name: "teamadmin", + Description: "team admin", + IncludesAllRepositories: true, + Permission: "admin", + } + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", org.Name), teamToCreate). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusCreated) + apiTeam = api.Team{} + DecodeJSON(t, resp, &apiTeam) + for _, ut := range unit.AllRepoUnitTypes { + up := perm.AccessModeAdmin + if ut == unit.TypeExternalTracker || ut == unit.TypeExternalWiki { + up = perm.AccessModeRead + } + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{ + OrgID: org.ID, + TeamID: apiTeam.ID, + Type: ut, + AccessMode: up, + }) + } + teamID = apiTeam.ID + + // Delete team. + req = NewRequestf(t, "DELETE", "/api/v1/teams/%d", teamID). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + unittest.AssertNotExistsBean(t, &organization.Team{ID: teamID}) +} + +func checkTeamResponse(t *testing.T, testName string, apiTeam *api.Team, name, description string, includesAllRepositories bool, permission string, units []string, unitsMap map[string]string) { + t.Run(testName, func(t *testing.T) { + assert.Equal(t, name, apiTeam.Name, "name") + assert.Equal(t, description, apiTeam.Description, "description") + assert.Equal(t, includesAllRepositories, apiTeam.IncludesAllRepositories, "includesAllRepositories") + assert.Equal(t, permission, apiTeam.Permission, "permission") + if units != nil { + sort.StringSlice(units).Sort() + sort.StringSlice(apiTeam.Units).Sort() + assert.EqualValues(t, units, apiTeam.Units, "units") + } + if unitsMap != nil { + assert.EqualValues(t, unitsMap, apiTeam.UnitsMap, "unitsMap") + } + }) +} + +func checkTeamBean(t *testing.T, id int64, name, description string, includesAllRepositories bool, permission string, units []string, unitsMap map[string]string) { + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: id}) + require.NoError(t, team.LoadUnits(db.DefaultContext), "LoadUnits") + apiTeam, err := convert.ToTeam(db.DefaultContext, team) + require.NoError(t, err) + checkTeamResponse(t, fmt.Sprintf("checkTeamBean/%s_%s", name, description), apiTeam, name, description, includesAllRepositories, permission, units, unitsMap) +} + +type TeamSearchResults struct { + OK bool `json:"ok"` + Data []*api.Team `json:"data"` +} + +func TestAPITeamSearch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17}) + + var results TeamSearchResults + + token := getUserToken(t, user.Name, auth_model.AccessTokenScopeReadOrganization) + req := NewRequestf(t, "GET", "/api/v1/orgs/%s/teams/search?q=%s", org.Name, "_team"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &results) + assert.NotEmpty(t, results.Data) + assert.Len(t, results.Data, 1) + assert.Equal(t, "test_team", results.Data[0].Name) + + // no access if not organization member + user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + token5 := getUserToken(t, user5.Name, auth_model.AccessTokenScopeReadOrganization) + + req = NewRequestf(t, "GET", "/api/v1/orgs/%s/teams/search?q=%s", org.Name, "team"). + AddTokenAuth(token5) + MakeRequest(t, req, http.StatusForbidden) +} + +func TestAPIGetTeamRepo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15}) + teamRepo := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 24}) + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 5}) + + var results api.Repository + + token := getUserToken(t, user.Name, auth_model.AccessTokenScopeReadOrganization) + req := NewRequestf(t, "GET", "/api/v1/teams/%d/repos/%s/", team.ID, teamRepo.FullName()). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &results) + assert.Equal(t, "big_test_private_4", teamRepo.Name) + + // no access if not organization member + user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + token5 := getUserToken(t, user5.Name, auth_model.AccessTokenScopeReadOrganization) + + req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos/%s/", team.ID, teamRepo.FullName()). + AddTokenAuth(token5) + MakeRequest(t, req, http.StatusNotFound) +} diff --git a/tests/integration/api_team_user_test.go b/tests/integration/api_team_user_test.go new file mode 100644 index 0000000..6c80bc9 --- /dev/null +++ b/tests/integration/api_team_user_test.go @@ -0,0 +1,49 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPITeamUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + normalUsername := "user2" + session := loginUser(t, normalUsername) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization) + req := NewRequest(t, "GET", "/api/v1/teams/1/members/user1"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", "/api/v1/teams/1/members/user2"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var user2 *api.User + DecodeJSON(t, resp, &user2) + user2.Created = user2.Created.In(time.Local) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}) + + expectedUser := convert.ToUser(db.DefaultContext, user, user) + + // test time via unix timestamp + assert.EqualValues(t, expectedUser.LastLogin.Unix(), user2.LastLogin.Unix()) + assert.EqualValues(t, expectedUser.Created.Unix(), user2.Created.Unix()) + expectedUser.LastLogin = user2.LastLogin + expectedUser.Created = user2.Created + + assert.Equal(t, expectedUser, user2) +} diff --git a/tests/integration/api_token_test.go b/tests/integration/api_token_test.go new file mode 100644 index 0000000..01d18ef --- /dev/null +++ b/tests/integration/api_token_test.go @@ -0,0 +1,565 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +// TestAPICreateAndDeleteToken tests that token that was just created can be deleted +func TestAPICreateAndDeleteToken(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + newAccessToken := createAPIAccessTokenWithoutCleanUp(t, "test-key-1", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll}) + deleteAPIAccessToken(t, newAccessToken, user) + + newAccessToken = createAPIAccessTokenWithoutCleanUp(t, "test-key-2", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll}) + deleteAPIAccessToken(t, newAccessToken, user) +} + +// TestAPIDeleteMissingToken ensures that error is thrown when token not found +func TestAPIDeleteMissingToken(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + req := NewRequestf(t, "DELETE", "/api/v1/users/user1/tokens/%d", unittest.NonexistentID). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNotFound) +} + +// TestAPIGetTokensPermission ensures that only the admin can get tokens from other users +func TestAPIGetTokensPermission(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // admin can get tokens for other users + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + req := NewRequest(t, "GET", "/api/v1/users/user2/tokens"). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + // non-admin can get tokens for himself + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + req = NewRequest(t, "GET", "/api/v1/users/user2/tokens"). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + // non-admin can't get tokens for other users + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + req = NewRequest(t, "GET", "/api/v1/users/user2/tokens"). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusForbidden) +} + +// TestAPIDeleteTokensPermission ensures that only the admin can delete tokens from other users +func TestAPIDeleteTokensPermission(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + + // admin can delete tokens for other users + createAPIAccessTokenWithoutCleanUp(t, "test-key-1", user2, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll}) + req := NewRequest(t, "DELETE", "/api/v1/users/"+user2.LoginName+"/tokens/test-key-1"). + AddBasicAuth(admin.Name) + MakeRequest(t, req, http.StatusNoContent) + + // non-admin can delete tokens for himself + createAPIAccessTokenWithoutCleanUp(t, "test-key-2", user2, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll}) + req = NewRequest(t, "DELETE", "/api/v1/users/"+user2.LoginName+"/tokens/test-key-2"). + AddBasicAuth(user2.Name) + MakeRequest(t, req, http.StatusNoContent) + + // non-admin can't delete tokens for other users + createAPIAccessTokenWithoutCleanUp(t, "test-key-3", user2, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll}) + req = NewRequest(t, "DELETE", "/api/v1/users/"+user2.LoginName+"/tokens/test-key-3"). + AddBasicAuth(user4.Name) + MakeRequest(t, req, http.StatusForbidden) +} + +type permission struct { + category auth_model.AccessTokenScopeCategory + level auth_model.AccessTokenScopeLevel +} + +type requiredScopeTestCase struct { + url string + method string + requiredPermissions []permission +} + +func (c *requiredScopeTestCase) Name() string { + return fmt.Sprintf("%v %v", c.method, c.url) +} + +// TestAPIDeniesPermissionBasedOnTokenScope tests that API routes forbid access +// when the correct token scope is not included. +func TestAPIDeniesPermissionBasedOnTokenScope(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // We'll assert that each endpoint, when fetched with a token with all + // scopes *except* the ones specified, a forbidden status code is returned. + // + // This is to protect against endpoints having their access check copied + // from other endpoints and not updated. + // + // Test cases are in alphabetical order by URL. + testCases := []requiredScopeTestCase{ + { + "/api/v1/admin/emails", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryAdmin, + auth_model.Read, + }, + }, + }, + { + "/api/v1/admin/users", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryAdmin, + auth_model.Read, + }, + }, + }, + { + "/api/v1/admin/users", + "POST", + []permission{ + { + auth_model.AccessTokenScopeCategoryAdmin, + auth_model.Write, + }, + }, + }, + { + "/api/v1/admin/users/user2", + "PATCH", + []permission{ + { + auth_model.AccessTokenScopeCategoryAdmin, + auth_model.Write, + }, + }, + }, + { + "/api/v1/admin/users/user2/orgs", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryAdmin, + auth_model.Read, + }, + }, + }, + { + "/api/v1/admin/users/user2/orgs", + "POST", + []permission{ + { + auth_model.AccessTokenScopeCategoryAdmin, + auth_model.Write, + }, + }, + }, + { + "/api/v1/admin/orgs", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryAdmin, + auth_model.Read, + }, + }, + }, + { + "/api/v1/notifications", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryNotification, + auth_model.Read, + }, + }, + }, + { + "/api/v1/notifications", + "PUT", + []permission{ + { + auth_model.AccessTokenScopeCategoryNotification, + auth_model.Write, + }, + }, + }, + { + "/api/v1/org/org1/repos", + "POST", + []permission{ + { + auth_model.AccessTokenScopeCategoryOrganization, + auth_model.Write, + }, + { + auth_model.AccessTokenScopeCategoryRepository, + auth_model.Write, + }, + }, + }, + { + "/api/v1/packages/user1/type/name/1", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryPackage, + auth_model.Read, + }, + }, + }, + { + "/api/v1/packages/user1/type/name/1", + "DELETE", + []permission{ + { + auth_model.AccessTokenScopeCategoryPackage, + auth_model.Write, + }, + }, + }, + { + "/api/v1/repos/user1/repo1", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryRepository, + auth_model.Read, + }, + }, + }, + { + "/api/v1/repos/user1/repo1", + "PATCH", + []permission{ + { + auth_model.AccessTokenScopeCategoryRepository, + auth_model.Write, + }, + }, + }, + { + "/api/v1/repos/user1/repo1", + "DELETE", + []permission{ + { + auth_model.AccessTokenScopeCategoryRepository, + auth_model.Write, + }, + }, + }, + { + "/api/v1/repos/user1/repo1/branches", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryRepository, + auth_model.Read, + }, + }, + }, + { + "/api/v1/repos/user1/repo1/archive/foo", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryRepository, + auth_model.Read, + }, + }, + }, + { + "/api/v1/repos/user1/repo1/issues", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryIssue, + auth_model.Read, + }, + }, + }, + { + "/api/v1/repos/user1/repo1/media/foo", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryRepository, + auth_model.Read, + }, + }, + }, + { + "/api/v1/repos/user1/repo1/raw/foo", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryRepository, + auth_model.Read, + }, + }, + }, + { + "/api/v1/repos/user1/repo1/teams", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryRepository, + auth_model.Read, + }, + }, + }, + { + "/api/v1/repos/user1/repo1/teams/team1", + "PUT", + []permission{ + { + auth_model.AccessTokenScopeCategoryRepository, + auth_model.Write, + }, + }, + }, + { + "/api/v1/repos/user1/repo1/transfer", + "POST", + []permission{ + { + auth_model.AccessTokenScopeCategoryRepository, + auth_model.Write, + }, + }, + }, + // Private repo + { + "/api/v1/repos/user2/repo2", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryRepository, + auth_model.Read, + }, + }, + }, + // Private repo + { + "/api/v1/repos/user2/repo2", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryRepository, + auth_model.Read, + }, + }, + }, + { + "/api/v1/user", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryUser, + auth_model.Read, + }, + }, + }, + { + "/api/v1/user/emails", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryUser, + auth_model.Read, + }, + }, + }, + { + "/api/v1/user/emails", + "POST", + []permission{ + { + auth_model.AccessTokenScopeCategoryUser, + auth_model.Write, + }, + }, + }, + { + "/api/v1/user/emails", + "DELETE", + []permission{ + { + auth_model.AccessTokenScopeCategoryUser, + auth_model.Write, + }, + }, + }, + { + "/api/v1/user/applications/oauth2", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryUser, + auth_model.Read, + }, + }, + }, + { + "/api/v1/user/applications/oauth2", + "POST", + []permission{ + { + auth_model.AccessTokenScopeCategoryUser, + auth_model.Write, + }, + }, + }, + { + "/api/v1/users/search", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryUser, + auth_model.Read, + }, + }, + }, + // Private user + { + "/api/v1/users/user31", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryUser, + auth_model.Read, + }, + }, + }, + // Private user + { + "/api/v1/users/user31/gpg_keys", + "GET", + []permission{ + { + auth_model.AccessTokenScopeCategoryUser, + auth_model.Read, + }, + }, + }, + } + + // User needs to be admin so that we can verify that tokens without admin + // scopes correctly deny access. + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + assert.True(t, user.IsAdmin, "User needs to be admin") + + for _, testCase := range testCases { + runTestCase(t, &testCase, user) + } +} + +// runTestCase Helper function to run a single test case. +func runTestCase(t *testing.T, testCase *requiredScopeTestCase, user *user_model.User) { + t.Run(testCase.Name(), func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create a token with all scopes NOT required by the endpoint. + var unauthorizedScopes []auth_model.AccessTokenScope + for _, category := range auth_model.AllAccessTokenScopeCategories { + // For permissions, Write > Read > NoAccess. So we need to + // find the minimum required, and only grant permission up to but + // not including the minimum required. + minRequiredLevel := auth_model.Write + categoryIsRequired := false + for _, requiredPermission := range testCase.requiredPermissions { + if requiredPermission.category != category { + continue + } + categoryIsRequired = true + if requiredPermission.level < minRequiredLevel { + minRequiredLevel = requiredPermission.level + } + } + unauthorizedLevel := auth_model.Write + if categoryIsRequired { + if minRequiredLevel == auth_model.Read { + unauthorizedLevel = auth_model.NoAccess + } else if minRequiredLevel == auth_model.Write { + unauthorizedLevel = auth_model.Read + } else { + assert.FailNow(t, "Invalid test case: Unknown access token scope level: %v", minRequiredLevel) + } + } + + if unauthorizedLevel == auth_model.NoAccess { + continue + } + cateogoryUnauthorizedScopes := auth_model.GetRequiredScopes( + unauthorizedLevel, + category) + unauthorizedScopes = append(unauthorizedScopes, cateogoryUnauthorizedScopes...) + } + + accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-token", user, unauthorizedScopes) + defer deleteAPIAccessToken(t, accessToken, user) + + // Request the endpoint. Verify that permission is denied. + req := NewRequest(t, testCase.method, testCase.url). + AddTokenAuth(accessToken.Token) + MakeRequest(t, req, http.StatusForbidden) + }) +} + +// createAPIAccessTokenWithoutCleanUp Create an API access token and assert that +// creation succeeded. The caller is responsible for deleting the token. +func createAPIAccessTokenWithoutCleanUp(t *testing.T, tokenName string, user *user_model.User, scopes []auth_model.AccessTokenScope) api.AccessToken { + payload := map[string]any{ + "name": tokenName, + "scopes": scopes, + } + + log.Debug("Requesting creation of token with scopes: %v", scopes) + req := NewRequestWithJSON(t, "POST", "/api/v1/users/"+user.LoginName+"/tokens", payload). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusCreated) + + var newAccessToken api.AccessToken + DecodeJSON(t, resp, &newAccessToken) + unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{ + ID: newAccessToken.ID, + Name: newAccessToken.Name, + Token: newAccessToken.Token, + UID: user.ID, + }) + + return newAccessToken +} + +// deleteAPIAccessToken deletes an API access token and assert that deletion succeeded. +func deleteAPIAccessToken(t *testing.T, accessToken api.AccessToken, user *user_model.User) { + req := NewRequestf(t, "DELETE", "/api/v1/users/"+user.LoginName+"/tokens/%d", accessToken.ID). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: accessToken.ID}) +} diff --git a/tests/integration/api_twofa_test.go b/tests/integration/api_twofa_test.go new file mode 100644 index 0000000..fb1d2ba --- /dev/null +++ b/tests/integration/api_twofa_test.go @@ -0,0 +1,82 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/tests" + + "github.com/pquerna/otp/totp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPITwoFactor(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 16}) + + req := NewRequest(t, "GET", "/api/v1/user"). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusOK) + + otpKey, err := totp.Generate(totp.GenerateOpts{ + SecretSize: 40, + Issuer: "gitea-test", + AccountName: user.Name, + }) + require.NoError(t, err) + + tfa := &auth_model.TwoFactor{ + UID: user.ID, + } + require.NoError(t, tfa.SetSecret(otpKey.Secret())) + + require.NoError(t, auth_model.NewTwoFactor(db.DefaultContext, tfa)) + + req = NewRequest(t, "GET", "/api/v1/user"). + AddBasicAuth(user.Name) + MakeRequest(t, req, http.StatusUnauthorized) + + passcode, err := totp.GenerateCode(otpKey.Secret(), time.Now()) + require.NoError(t, err) + + req = NewRequest(t, "GET", "/api/v1/user"). + AddBasicAuth(user.Name) + req.Header.Set("X-Gitea-OTP", passcode) + MakeRequest(t, req, http.StatusOK) + + req = NewRequestf(t, "GET", "/api/v1/user"). + AddBasicAuth(user.Name) + req.Header.Set("X-Forgejo-OTP", passcode) + MakeRequest(t, req, http.StatusOK) +} + +func TestAPIWebAuthn(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 32}) + unittest.AssertExistsAndLoadBean(t, &auth_model.WebAuthnCredential{UserID: user.ID}) + + req := NewRequest(t, "GET", "/api/v1/user") + req.SetBasicAuth(user.Name, "notpassword") + + resp := MakeRequest(t, req, http.StatusUnauthorized) + + type userResponse struct { + Message string `json:"message"` + } + var userParsed userResponse + + DecodeJSON(t, resp, &userParsed) + + assert.EqualValues(t, "Basic authorization is not allowed while having security keys enrolled", userParsed.Message) +} diff --git a/tests/integration/api_user_avatar_test.go b/tests/integration/api_user_avatar_test.go new file mode 100644 index 0000000..22dc09a --- /dev/null +++ b/tests/integration/api_user_avatar_test.go @@ -0,0 +1,77 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "net/http" + "os" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIUpdateUserAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + normalUsername := "user2" + session := loginUser(t, normalUsername) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + // Test what happens if you use a valid image + avatar, err := os.ReadFile("tests/integration/avatar.png") + require.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open avatar.png") + } + + // Test what happens if you don't have a valid Base64 string + opts := api.UpdateUserAvatarOption{ + Image: base64.StdEncoding.EncodeToString(avatar), + } + + req := NewRequestWithJSON(t, "POST", "/api/v1/user/avatar", &opts). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + opts = api.UpdateUserAvatarOption{ + Image: "Invalid", + } + + req = NewRequestWithJSON(t, "POST", "/api/v1/user/avatar", &opts). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusBadRequest) + + // Test what happens if you use a file that is not an image + text, err := os.ReadFile("tests/integration/README.md") + require.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open README.md") + } + + opts = api.UpdateUserAvatarOption{ + Image: base64.StdEncoding.EncodeToString(text), + } + + req = NewRequestWithJSON(t, "POST", "/api/v1/user/avatar", &opts). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusInternalServerError) +} + +func TestAPIDeleteUserAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + normalUsername := "user2" + session := loginUser(t, normalUsername) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + req := NewRequest(t, "DELETE", "/api/v1/user/avatar"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) +} diff --git a/tests/integration/api_user_email_test.go b/tests/integration/api_user_email_test.go new file mode 100644 index 0000000..6441e2e --- /dev/null +++ b/tests/integration/api_user_email_test.go @@ -0,0 +1,129 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIListEmails(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + normalUsername := "user2" + session := loginUser(t, normalUsername) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser) + + req := NewRequest(t, "GET", "/api/v1/user/emails"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var emails []*api.Email + DecodeJSON(t, resp, &emails) + + assert.EqualValues(t, []*api.Email{ + { + Email: "user2@example.com", + Verified: true, + Primary: true, + }, + { + Email: "user2-2@example.com", + Verified: false, + Primary: false, + }, + }, emails) +} + +func TestAPIAddEmail(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + normalUsername := "user2" + session := loginUser(t, normalUsername) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + opts := api.CreateEmailOption{ + Emails: []string{"user101@example.com"}, + } + + req := NewRequestWithJSON(t, "POST", "/api/v1/user/emails", &opts). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + opts = api.CreateEmailOption{ + Emails: []string{"user2-3@example.com"}, + } + req = NewRequestWithJSON(t, "POST", "/api/v1/user/emails", &opts). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var emails []*api.Email + DecodeJSON(t, resp, &emails) + assert.EqualValues(t, []*api.Email{ + { + Email: "user2@example.com", + Verified: true, + Primary: true, + }, + { + Email: "user2-2@example.com", + Verified: false, + Primary: false, + }, + { + Email: "user2-3@example.com", + Verified: true, + Primary: false, + }, + }, emails) + + opts = api.CreateEmailOption{ + Emails: []string{"notAEmail"}, + } + req = NewRequestWithJSON(t, "POST", "/api/v1/user/emails", &opts). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusUnprocessableEntity) +} + +func TestAPIDeleteEmail(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + normalUsername := "user2" + session := loginUser(t, normalUsername) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + opts := api.DeleteEmailOption{ + Emails: []string{"user2-3@example.com"}, + } + req := NewRequestWithJSON(t, "DELETE", "/api/v1/user/emails", &opts). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + opts = api.DeleteEmailOption{ + Emails: []string{"user2-2@example.com"}, + } + req = NewRequestWithJSON(t, "DELETE", "/api/v1/user/emails", &opts). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "GET", "/api/v1/user/emails"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var emails []*api.Email + DecodeJSON(t, resp, &emails) + assert.EqualValues(t, []*api.Email{ + { + Email: "user2@example.com", + Verified: true, + Primary: true, + }, + }, emails) +} diff --git a/tests/integration/api_user_follow_test.go b/tests/integration/api_user_follow_test.go new file mode 100644 index 0000000..68443ef --- /dev/null +++ b/tests/integration/api_user_follow_test.go @@ -0,0 +1,121 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIFollow(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user1 := "user4" + user2 := "user10" + + session1 := loginUser(t, user1) + token1 := getTokenForLoggedInUser(t, session1, auth_model.AccessTokenScopeReadUser) + + session2 := loginUser(t, user2) + token2 := getTokenForLoggedInUser(t, session2, auth_model.AccessTokenScopeWriteUser) + + t.Run("Follow", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/following/%s", user1)). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusNoContent) + }) + + t.Run("ListFollowing", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/following", user2)). + AddTokenAuth(token2) + resp := MakeRequest(t, req, http.StatusOK) + + var users []api.User + DecodeJSON(t, resp, &users) + assert.Len(t, users, 1) + assert.Equal(t, user1, users[0].UserName) + }) + + t.Run("ListMyFollowing", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user/following"). + AddTokenAuth(token2) + resp := MakeRequest(t, req, http.StatusOK) + + var users []api.User + DecodeJSON(t, resp, &users) + assert.Len(t, users, 1) + assert.Equal(t, user1, users[0].UserName) + }) + + t.Run("ListFollowers", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/followers", user1)). + AddTokenAuth(token1) + resp := MakeRequest(t, req, http.StatusOK) + + var users []api.User + DecodeJSON(t, resp, &users) + assert.Len(t, users, 1) + assert.Equal(t, user2, users[0].UserName) + }) + + t.Run("ListMyFollowers", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user/followers"). + AddTokenAuth(token1) + resp := MakeRequest(t, req, http.StatusOK) + + var users []api.User + DecodeJSON(t, resp, &users) + assert.Len(t, users, 1) + assert.Equal(t, user2, users[0].UserName) + }) + + t.Run("CheckFollowing", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/following/%s", user2, user1)). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/following/%s", user1, user2)). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("CheckMyFollowing", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/following/%s", user1)). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/following/%s", user2)). + AddTokenAuth(token1) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("Unfollow", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/user/following/%s", user1)). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusNoContent) + }) +} diff --git a/tests/integration/api_user_heatmap_test.go b/tests/integration/api_user_heatmap_test.go new file mode 100644 index 0000000..a235367 --- /dev/null +++ b/tests/integration/api_user_heatmap_test.go @@ -0,0 +1,39 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + "time" + + activities_model "code.gitea.io/gitea/models/activities" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestUserHeatmap(t *testing.T) { + defer tests.PrepareTestEnv(t)() + adminUsername := "user1" + normalUsername := "user2" + token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeReadUser) + + fakeNow := time.Date(2011, 10, 20, 0, 0, 0, 0, time.Local) + timeutil.MockSet(fakeNow) + defer timeutil.MockUnset() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/heatmap", normalUsername)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var heatmap []*activities_model.UserHeatmapData + DecodeJSON(t, resp, &heatmap) + var dummyheatmap []*activities_model.UserHeatmapData + dummyheatmap = append(dummyheatmap, &activities_model.UserHeatmapData{Timestamp: 1603227600, Contributions: 1}) + + assert.Equal(t, dummyheatmap, heatmap) +} diff --git a/tests/integration/api_user_info_test.go b/tests/integration/api_user_info_test.go new file mode 100644 index 0000000..89f7266 --- /dev/null +++ b/tests/integration/api_user_info_test.go @@ -0,0 +1,70 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIUserInfo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := "user1" + user2 := "user31" + + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org3"}) + + session := loginUser(t, user) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser) + + t.Run("GetInfo", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s", user2)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var u api.User + DecodeJSON(t, resp, &u) + assert.Equal(t, user2, u.UserName) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s", user2)) + MakeRequest(t, req, http.StatusNotFound) + + // test if the placaholder Mail is returned if a User is not logged in + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s", org3.Name)) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &u) + assert.Equal(t, org3.GetPlaceholderEmail(), u.Email) + + // Test if the correct Mail is returned if a User is logged in + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s", org3.Name)). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &u) + assert.Equal(t, org3.GetEmail(), u.Email) + }) + + t.Run("GetAuthenticatedUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var u api.User + DecodeJSON(t, resp, &u) + assert.Equal(t, user, u.UserName) + }) +} diff --git a/tests/integration/api_user_org_perm_test.go b/tests/integration/api_user_org_perm_test.go new file mode 100644 index 0000000..85bb1db --- /dev/null +++ b/tests/integration/api_user_org_perm_test.go @@ -0,0 +1,153 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +type apiUserOrgPermTestCase struct { + LoginUser string + User string + Organization string + ExpectedOrganizationPermissions api.OrganizationPermissions +} + +func TestTokenNeeded(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/api/v1/users/user1/orgs/org6/permissions") + MakeRequest(t, req, http.StatusUnauthorized) +} + +func sampleTest(t *testing.T, auoptc apiUserOrgPermTestCase) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, auoptc.LoginUser) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadUser) + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/orgs/%s/permissions", auoptc.User, auoptc.Organization)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var apiOP api.OrganizationPermissions + DecodeJSON(t, resp, &apiOP) + assert.Equal(t, auoptc.ExpectedOrganizationPermissions.IsOwner, apiOP.IsOwner) + assert.Equal(t, auoptc.ExpectedOrganizationPermissions.IsAdmin, apiOP.IsAdmin) + assert.Equal(t, auoptc.ExpectedOrganizationPermissions.CanWrite, apiOP.CanWrite) + assert.Equal(t, auoptc.ExpectedOrganizationPermissions.CanRead, apiOP.CanRead) + assert.Equal(t, auoptc.ExpectedOrganizationPermissions.CanCreateRepository, apiOP.CanCreateRepository) +} + +func TestWithOwnerUser(t *testing.T) { + sampleTest(t, apiUserOrgPermTestCase{ + LoginUser: "user2", + User: "user2", + Organization: "org3", + ExpectedOrganizationPermissions: api.OrganizationPermissions{ + IsOwner: true, + IsAdmin: true, + CanWrite: true, + CanRead: true, + CanCreateRepository: true, + }, + }) +} + +func TestCanWriteUser(t *testing.T) { + sampleTest(t, apiUserOrgPermTestCase{ + LoginUser: "user4", + User: "user4", + Organization: "org3", + ExpectedOrganizationPermissions: api.OrganizationPermissions{ + IsOwner: false, + IsAdmin: false, + CanWrite: true, + CanRead: true, + CanCreateRepository: false, + }, + }) +} + +func TestAdminUser(t *testing.T) { + sampleTest(t, apiUserOrgPermTestCase{ + LoginUser: "user1", + User: "user28", + Organization: "org3", + ExpectedOrganizationPermissions: api.OrganizationPermissions{ + IsOwner: false, + IsAdmin: true, + CanWrite: true, + CanRead: true, + CanCreateRepository: true, + }, + }) +} + +func TestAdminCanNotCreateRepo(t *testing.T) { + sampleTest(t, apiUserOrgPermTestCase{ + LoginUser: "user1", + User: "user28", + Organization: "org6", + ExpectedOrganizationPermissions: api.OrganizationPermissions{ + IsOwner: false, + IsAdmin: true, + CanWrite: true, + CanRead: true, + CanCreateRepository: false, + }, + }) +} + +func TestCanReadUser(t *testing.T) { + sampleTest(t, apiUserOrgPermTestCase{ + LoginUser: "user1", + User: "user24", + Organization: "org25", + ExpectedOrganizationPermissions: api.OrganizationPermissions{ + IsOwner: false, + IsAdmin: false, + CanWrite: false, + CanRead: true, + CanCreateRepository: false, + }, + }) +} + +func TestUnknowUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadOrganization) + + req := NewRequest(t, "GET", "/api/v1/users/unknow/orgs/org25/permissions"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusNotFound) + + var apiError api.APIError + DecodeJSON(t, resp, &apiError) + assert.Equal(t, "user redirect does not exist [name: unknow]", apiError.Message) +} + +func TestUnknowOrganization(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadOrganization) + + req := NewRequest(t, "GET", "/api/v1/users/user1/orgs/unknow/permissions"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusNotFound) + var apiError api.APIError + DecodeJSON(t, resp, &apiError) + assert.Equal(t, "GetUserByName", apiError.Message) +} diff --git a/tests/integration/api_user_orgs_test.go b/tests/integration/api_user_orgs_test.go new file mode 100644 index 0000000..e311994 --- /dev/null +++ b/tests/integration/api_user_orgs_test.go @@ -0,0 +1,132 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestUserOrgs(t *testing.T) { + defer tests.PrepareTestEnv(t)() + adminUsername := "user1" + normalUsername := "user2" + privateMemberUsername := "user4" + unrelatedUsername := "user5" + + orgs := getUserOrgs(t, adminUsername, normalUsername) + + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org3"}) + org17 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org17"}) + + assert.Equal(t, []*api.Organization{ + { + ID: 17, + Name: org17.Name, + UserName: org17.Name, + FullName: org17.FullName, + Email: org17.Email, + AvatarURL: org17.AvatarLink(db.DefaultContext), + Description: "", + Website: "", + Location: "", + Visibility: "public", + }, + { + ID: 3, + Name: org3.Name, + UserName: org3.Name, + FullName: org3.FullName, + Email: org3.Email, + AvatarURL: org3.AvatarLink(db.DefaultContext), + Description: "", + Website: "", + Location: "", + Visibility: "public", + }, + }, orgs) + + // user itself should get it's org's he is a member of + orgs = getUserOrgs(t, privateMemberUsername, privateMemberUsername) + assert.Len(t, orgs, 1) + + // unrelated user should not get private org membership of privateMemberUsername + orgs = getUserOrgs(t, unrelatedUsername, privateMemberUsername) + assert.Empty(t, orgs) + + // not authenticated call should not be allowed + testUserOrgsUnauthenticated(t, privateMemberUsername) +} + +func getUserOrgs(t *testing.T, userDoer, userCheck string) (orgs []*api.Organization) { + token := "" + if len(userDoer) != 0 { + token = getUserToken(t, userDoer, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadUser) + } + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/orgs", userCheck)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &orgs) + return orgs +} + +func testUserOrgsUnauthenticated(t *testing.T, userCheck string) { + session := emptyTestSession(t) + req := NewRequestf(t, "GET", "/api/v1/users/%s/orgs", userCheck) + session.MakeRequest(t, req, http.StatusUnauthorized) +} + +func TestMyOrgs(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/api/v1/user/orgs") + MakeRequest(t, req, http.StatusUnauthorized) + + normalUsername := "user2" + token := getUserToken(t, normalUsername, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadUser) + req = NewRequest(t, "GET", "/api/v1/user/orgs"). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var orgs []*api.Organization + DecodeJSON(t, resp, &orgs) + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org3"}) + org17 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org17"}) + + assert.Equal(t, []*api.Organization{ + { + ID: 17, + Name: org17.Name, + UserName: org17.Name, + FullName: org17.FullName, + Email: org17.Email, + AvatarURL: org17.AvatarLink(db.DefaultContext), + Description: "", + Website: "", + Location: "", + Visibility: "public", + }, + { + ID: 3, + Name: org3.Name, + UserName: org3.Name, + FullName: org3.FullName, + Email: org3.Email, + AvatarURL: org3.AvatarLink(db.DefaultContext), + Description: "", + Website: "", + Location: "", + Visibility: "public", + }, + }, orgs) +} diff --git a/tests/integration/api_user_search_test.go b/tests/integration/api_user_search_test.go new file mode 100644 index 0000000..97c42aa --- /dev/null +++ b/tests/integration/api_user_search_test.go @@ -0,0 +1,145 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +type SearchResults struct { + OK bool `json:"ok"` + Data []*api.User `json:"data"` +} + +func TestAPIUserSearchLoggedIn(t *testing.T) { + defer tests.PrepareTestEnv(t)() + adminUsername := "user1" + session := loginUser(t, adminUsername) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser) + query := "user2" + req := NewRequestf(t, "GET", "/api/v1/users/search?q=%s", query). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var results SearchResults + DecodeJSON(t, resp, &results) + assert.NotEmpty(t, results.Data) + for _, user := range results.Data { + assert.Contains(t, user.UserName, query) + assert.NotEmpty(t, user.Email) + } + + publicToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly) + req = NewRequestf(t, "GET", "/api/v1/users/search?q=%s", query). + AddTokenAuth(publicToken) + resp = MakeRequest(t, req, http.StatusOK) + results = SearchResults{} + DecodeJSON(t, resp, &results) + assert.NotEmpty(t, results.Data) + for _, user := range results.Data { + assert.Contains(t, user.UserName, query) + assert.NotEmpty(t, user.Email) + assert.Equal(t, "public", user.Visibility) + } +} + +func TestAPIUserSearchNotLoggedIn(t *testing.T) { + defer tests.PrepareTestEnv(t)() + query := "user2" + req := NewRequestf(t, "GET", "/api/v1/users/search?q=%s", query) + resp := MakeRequest(t, req, http.StatusOK) + + var results SearchResults + DecodeJSON(t, resp, &results) + assert.NotEmpty(t, results.Data) + var modelUser *user_model.User + for _, user := range results.Data { + assert.Contains(t, user.UserName, query) + modelUser = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: user.ID}) + assert.EqualValues(t, modelUser.GetPlaceholderEmail(), user.Email) + } +} + +func TestAPIUserSearchPaged(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.API.DefaultPagingNum, 5)() + + req := NewRequest(t, "GET", "/api/v1/users/search?limit=1") + resp := MakeRequest(t, req, http.StatusOK) + + var limitedResults SearchResults + DecodeJSON(t, resp, &limitedResults) + assert.Len(t, limitedResults.Data, 1) + + req = NewRequest(t, "GET", "/api/v1/users/search") + resp = MakeRequest(t, req, http.StatusOK) + + var results SearchResults + DecodeJSON(t, resp, &results) + assert.Len(t, results.Data, 5) +} + +func TestAPIUserSearchSystemUsers(t *testing.T) { + defer tests.PrepareTestEnv(t)() + for _, systemUser := range []*user_model.User{ + user_model.NewGhostUser(), + user_model.NewActionsUser(), + } { + t.Run(systemUser.Name, func(t *testing.T) { + req := NewRequestf(t, "GET", "/api/v1/users/search?uid=%d", systemUser.ID) + resp := MakeRequest(t, req, http.StatusOK) + + var results SearchResults + DecodeJSON(t, resp, &results) + assert.NotEmpty(t, results.Data) + if assert.Len(t, results.Data, 1) { + user := results.Data[0] + assert.EqualValues(t, user.UserName, systemUser.Name) + assert.EqualValues(t, user.ID, systemUser.ID) + } + }) + } +} + +func TestAPIUserSearchAdminLoggedInUserHidden(t *testing.T) { + defer tests.PrepareTestEnv(t)() + adminUsername := "user1" + session := loginUser(t, adminUsername) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser) + query := "user31" + req := NewRequestf(t, "GET", "/api/v1/users/search?q=%s", query). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var results SearchResults + DecodeJSON(t, resp, &results) + assert.NotEmpty(t, results.Data) + for _, user := range results.Data { + assert.Contains(t, user.UserName, query) + assert.NotEmpty(t, user.Email) + assert.EqualValues(t, "private", user.Visibility) + } +} + +func TestAPIUserSearchNotLoggedInUserHidden(t *testing.T) { + defer tests.PrepareTestEnv(t)() + query := "user31" + req := NewRequestf(t, "GET", "/api/v1/users/search?q=%s", query) + resp := MakeRequest(t, req, http.StatusOK) + + var results SearchResults + DecodeJSON(t, resp, &results) + assert.Empty(t, results.Data) +} diff --git a/tests/integration/api_user_secrets_test.go b/tests/integration/api_user_secrets_test.go new file mode 100644 index 0000000..56bf30e --- /dev/null +++ b/tests/integration/api_user_secrets_test.go @@ -0,0 +1,101 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" +) + +func TestAPIUserSecrets(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + t.Run("Create", func(t *testing.T) { + cases := []struct { + Name string + ExpectedStatus int + }{ + { + Name: "", + ExpectedStatus: http.StatusNotFound, + }, + { + Name: "-", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "_", + ExpectedStatus: http.StatusCreated, + }, + { + Name: "secret", + ExpectedStatus: http.StatusCreated, + }, + { + Name: "2secret", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "GITEA_secret", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "GITHUB_secret", + ExpectedStatus: http.StatusBadRequest, + }, + } + + for _, c := range cases { + req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/user/actions/secrets/%s", c.Name), api.CreateOrUpdateSecretOption{ + Data: "data", + }).AddTokenAuth(token) + MakeRequest(t, req, c.ExpectedStatus) + } + }) + + t.Run("Update", func(t *testing.T) { + name := "update_secret" + url := fmt.Sprintf("/api/v1/user/actions/secrets/%s", name) + + req := NewRequestWithJSON(t, "PUT", url, api.CreateOrUpdateSecretOption{ + Data: "initial", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequestWithJSON(t, "PUT", url, api.CreateOrUpdateSecretOption{ + Data: "changed", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + }) + + t.Run("Delete", func(t *testing.T) { + name := "delete_secret" + url := fmt.Sprintf("/api/v1/user/actions/secrets/%s", name) + + req := NewRequestWithJSON(t, "PUT", url, api.CreateOrUpdateSecretOption{ + Data: "initial", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + + req = NewRequest(t, "DELETE", url). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "DELETE", url). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "DELETE", "/api/v1/user/actions/secrets/000"). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusBadRequest) + }) +} diff --git a/tests/integration/api_user_star_test.go b/tests/integration/api_user_star_test.go new file mode 100644 index 0000000..aafe9cf --- /dev/null +++ b/tests/integration/api_user_star_test.go @@ -0,0 +1,118 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIStar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := "user1" + repo := "user2/repo1" + + session := loginUser(t, user) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser) + tokenWithUserScope := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository) + + assertDisabledStarsNotFound := func(t *testing.T, req *RequestWrapper) { + t.Helper() + + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&setting.Repository.DisableStars, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + MakeRequest(t, req, http.StatusNotFound) + } + + t.Run("Star", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/starred/%s", repo)). + AddTokenAuth(tokenWithUserScope) + MakeRequest(t, req, http.StatusNoContent) + + t.Run("disabled stars", func(t *testing.T) { + assertDisabledStarsNotFound(t, req) + }) + }) + + t.Run("GetStarredRepos", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/starred", user)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "1", resp.Header().Get("X-Total-Count")) + + var repos []api.Repository + DecodeJSON(t, resp, &repos) + assert.Len(t, repos, 1) + assert.Equal(t, repo, repos[0].FullName) + + t.Run("disabled stars", func(t *testing.T) { + assertDisabledStarsNotFound(t, req) + }) + }) + + t.Run("GetMyStarredRepos", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user/starred"). + AddTokenAuth(tokenWithUserScope) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "1", resp.Header().Get("X-Total-Count")) + + var repos []api.Repository + DecodeJSON(t, resp, &repos) + assert.Len(t, repos, 1) + assert.Equal(t, repo, repos[0].FullName) + + t.Run("disabled stars", func(t *testing.T) { + assertDisabledStarsNotFound(t, req) + }) + }) + + t.Run("IsStarring", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/starred/%s", repo)). + AddTokenAuth(tokenWithUserScope) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/starred/%s", repo+"notexisting")). + AddTokenAuth(tokenWithUserScope) + MakeRequest(t, req, http.StatusNotFound) + + t.Run("disabled stars", func(t *testing.T) { + assertDisabledStarsNotFound(t, req) + }) + }) + + t.Run("Unstar", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/user/starred/%s", repo)). + AddTokenAuth(tokenWithUserScope) + MakeRequest(t, req, http.StatusNoContent) + + t.Run("disabled stars", func(t *testing.T) { + assertDisabledStarsNotFound(t, req) + }) + }) +} diff --git a/tests/integration/api_user_variables_test.go b/tests/integration/api_user_variables_test.go new file mode 100644 index 0000000..9fd84dd --- /dev/null +++ b/tests/integration/api_user_variables_test.go @@ -0,0 +1,144 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" +) + +func TestAPIUserVariables(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + t.Run("CreateUserVariable", func(t *testing.T) { + cases := []struct { + Name string + ExpectedStatus int + }{ + { + Name: "-", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "_", + ExpectedStatus: http.StatusNoContent, + }, + { + Name: "TEST_VAR", + ExpectedStatus: http.StatusNoContent, + }, + { + Name: "test_var", + ExpectedStatus: http.StatusConflict, + }, + { + Name: "ci", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "123var", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "var@test", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "github_var", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: "gitea_var", + ExpectedStatus: http.StatusBadRequest, + }, + } + + for _, c := range cases { + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/user/actions/variables/%s", c.Name), api.CreateVariableOption{ + Value: "value", + }).AddTokenAuth(token) + MakeRequest(t, req, c.ExpectedStatus) + } + }) + + t.Run("UpdateUserVariable", func(t *testing.T) { + variableName := "test_update_var" + url := fmt.Sprintf("/api/v1/user/actions/variables/%s", variableName) + req := NewRequestWithJSON(t, "POST", url, api.CreateVariableOption{ + Value: "initial_val", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + cases := []struct { + Name string + UpdateName string + ExpectedStatus int + }{ + { + Name: "not_found_var", + ExpectedStatus: http.StatusNotFound, + }, + { + Name: variableName, + UpdateName: "1invalid", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: variableName, + UpdateName: "invalid@name", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: variableName, + UpdateName: "ci", + ExpectedStatus: http.StatusBadRequest, + }, + { + Name: variableName, + UpdateName: "updated_var_name", + ExpectedStatus: http.StatusNoContent, + }, + { + Name: variableName, + ExpectedStatus: http.StatusNotFound, + }, + { + Name: "updated_var_name", + ExpectedStatus: http.StatusNoContent, + }, + } + + for _, c := range cases { + req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/user/actions/variables/%s", c.Name), api.UpdateVariableOption{ + Name: c.UpdateName, + Value: "updated_val", + }).AddTokenAuth(token) + MakeRequest(t, req, c.ExpectedStatus) + } + }) + + t.Run("DeleteRepoVariable", func(t *testing.T) { + variableName := "test_delete_var" + url := fmt.Sprintf("/api/v1/user/actions/variables/%s", variableName) + + req := NewRequestWithJSON(t, "POST", url, api.CreateVariableOption{ + Value: "initial_val", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "DELETE", url).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "DELETE", url).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + }) +} diff --git a/tests/integration/api_user_watch_test.go b/tests/integration/api_user_watch_test.go new file mode 100644 index 0000000..953e005 --- /dev/null +++ b/tests/integration/api_user_watch_test.go @@ -0,0 +1,88 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIWatch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := "user1" + repo := "user2/repo1" + + session := loginUser(t, user) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser) + tokenWithRepoScope := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeReadUser) + + t.Run("Watch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/subscription", repo)). + AddTokenAuth(tokenWithRepoScope) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("GetWatchedRepos", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/subscriptions", user)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "1", resp.Header().Get("X-Total-Count")) + + var repos []api.Repository + DecodeJSON(t, resp, &repos) + assert.Len(t, repos, 1) + assert.Equal(t, repo, repos[0].FullName) + }) + + t.Run("GetMyWatchedRepos", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user/subscriptions"). + AddTokenAuth(tokenWithRepoScope) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "1", resp.Header().Get("X-Total-Count")) + + var repos []api.Repository + DecodeJSON(t, resp, &repos) + assert.Len(t, repos, 1) + assert.Equal(t, repo, repos[0].FullName) + }) + + t.Run("IsWatching", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/subscription", repo)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/subscription", repo)). + AddTokenAuth(tokenWithRepoScope) + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/subscription", repo+"notexisting")). + AddTokenAuth(tokenWithRepoScope) + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("Unwatch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/subscription", repo)). + AddTokenAuth(tokenWithRepoScope) + MakeRequest(t, req, http.StatusNoContent) + }) +} diff --git a/tests/integration/api_wiki_test.go b/tests/integration/api_wiki_test.go new file mode 100644 index 0000000..ac868bb --- /dev/null +++ b/tests/integration/api_wiki_test.go @@ -0,0 +1,434 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/optional" + api "code.gitea.io/gitea/modules/structs" + repo_service "code.gitea.io/gitea/services/repository" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIRenameWikiBranch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + username := "user2" + session := loginUser(t, username) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + repoURLStr := fmt.Sprintf("/api/v1/repos/%s/%s", username, "repo1") + wikiBranch := "wiki" + req := NewRequestWithJSON(t, "PATCH", repoURLStr, &api.EditRepoOption{ + WikiBranch: &wikiBranch, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + assert.Equal(t, "wiki", repo.WikiBranch) + + req = NewRequest(t, "GET", repoURLStr) + resp := MakeRequest(t, req, http.StatusOK) + var repoData *api.Repository + DecodeJSON(t, resp, &repoData) + assert.Equal(t, "wiki", repoData.WikiBranch) +} + +func TestAPIGetWikiPage(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + username := "user2" + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/Home", username, "repo1") + + req := NewRequest(t, "GET", urlStr) + resp := MakeRequest(t, req, http.StatusOK) + var page *api.WikiPage + DecodeJSON(t, resp, &page) + + assert.Equal(t, &api.WikiPage{ + WikiPageMetaData: &api.WikiPageMetaData{ + Title: "Home", + HTMLURL: page.HTMLURL, + SubURL: "Home", + LastCommit: &api.WikiCommit{ + ID: "2c54faec6c45d31c1abfaecdab471eac6633738a", + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "Ethan Koenig", + Email: "ethantkoenig@gmail.com", + }, + Date: "2017-11-27T04:31:18Z", + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "Ethan Koenig", + Email: "ethantkoenig@gmail.com", + }, + Date: "2017-11-27T04:31:18Z", + }, + Message: "Add Home.md\n", + }, + }, + ContentBase64: base64.RawStdEncoding.EncodeToString( + []byte("# Home page\n\nThis is the home page!\n"), + ), + CommitCount: 1, + Sidebar: "", + Footer: "", + }, page) +} + +func TestAPIListWikiPages(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + username := "user2" + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/pages", username, "repo1") + + req := NewRequest(t, "GET", urlStr) + resp := MakeRequest(t, req, http.StatusOK) + + var meta []*api.WikiPageMetaData + DecodeJSON(t, resp, &meta) + + dummymeta := []*api.WikiPageMetaData{ + { + Title: "Home", + HTMLURL: meta[0].HTMLURL, + SubURL: "Home", + LastCommit: &api.WikiCommit{ + ID: "2c54faec6c45d31c1abfaecdab471eac6633738a", + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "Ethan Koenig", + Email: "ethantkoenig@gmail.com", + }, + Date: "2017-11-27T04:31:18Z", + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "Ethan Koenig", + Email: "ethantkoenig@gmail.com", + }, + Date: "2017-11-27T04:31:18Z", + }, + Message: "Add Home.md\n", + }, + }, + { + Title: "Long Page", + HTMLURL: meta[1].HTMLURL, + SubURL: "Long-Page", + LastCommit: &api.WikiCommit{ + ID: "d49ac742d44063dcf69d4e0afe725813b777dd89", + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "Oto Šťáva", + Email: "oto.stava@gmail.com", + }, + Date: "2024-11-23T11:16:51Z", + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "Oto Šťáva", + Email: "oto.stava@gmail.com", + }, + Date: "2024-11-23T11:16:51Z", + }, + Message: "add long page\n", + }, + }, + { + Title: "Page With Image", + HTMLURL: meta[2].HTMLURL, + SubURL: "Page-With-Image", + LastCommit: &api.WikiCommit{ + ID: "0cf15c3f66ec8384480ed9c3cf87c9e97fbb0ec3", + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "Gabriel Silva Simões", + Email: "simoes.sgabriel@gmail.com", + }, + Date: "2019-01-25T01:41:55Z", + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "Gabriel Silva Simões", + Email: "simoes.sgabriel@gmail.com", + }, + Date: "2019-01-25T01:41:55Z", + }, + Message: "Add jpeg.jpg and page with image\n", + }, + }, + { + Title: "Page With Spaced Name", + HTMLURL: meta[3].HTMLURL, + SubURL: "Page-With-Spaced-Name", + LastCommit: &api.WikiCommit{ + ID: "c10d10b7e655b3dab1f53176db57c8219a5488d6", + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "Gabriel Silva Simões", + Email: "simoes.sgabriel@gmail.com", + }, + Date: "2019-01-25T01:39:51Z", + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "Gabriel Silva Simões", + Email: "simoes.sgabriel@gmail.com", + }, + Date: "2019-01-25T01:39:51Z", + }, + Message: "Add page with spaced name\n", + }, + }, + { + Title: "Unescaped File", + HTMLURL: meta[4].HTMLURL, + SubURL: "Unescaped-File", + LastCommit: &api.WikiCommit{ + ID: "0dca5bd9b5d7ef937710e056f575e86c0184ba85", + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "6543", + Email: "6543@obermui.de", + }, + Date: "2021-07-19T16:42:46Z", + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "6543", + Email: "6543@obermui.de", + }, + Date: "2021-07-19T16:42:46Z", + }, + Message: "add unescaped file\n", + }, + }, + } + + assert.Equal(t, dummymeta, meta) +} + +func TestAPINewWikiPage(t *testing.T) { + for _, title := range []string{ + "New page", + "&&&&", + } { + defer tests.PrepareTestEnv(t)() + username := "user2" + session := loginUser(t, username) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/new", username, "repo1") + + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateWikiPageOptions{ + Title: title, + ContentBase64: base64.StdEncoding.EncodeToString([]byte("Wiki page content for API unit tests")), + Message: "", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusCreated) + } +} + +func TestAPIEditWikiPage(t *testing.T) { + defer tests.PrepareTestEnv(t)() + username := "user2" + session := loginUser(t, username) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/Page-With-Spaced-Name", username, "repo1") + + req := NewRequestWithJSON(t, "PATCH", urlStr, &api.CreateWikiPageOptions{ + Title: "edited title", + ContentBase64: base64.StdEncoding.EncodeToString([]byte("Edited wiki page content for API unit tests")), + Message: "", + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) +} + +func TestAPIEditOtherWikiPage(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // (drive-by-user) user, session, and token for a drive-by wiki editor + username := "drive-by-user" + req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{ + "user_name": username, + "email": "drive-by@example.com", + "password": "examplePassword!1", + "retype": "examplePassword!1", + }) + MakeRequest(t, req, http.StatusSeeOther) + session := loginUserWithPassword(t, username, "examplePassword!1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // (user2) user for the user whose wiki we're going to edit (as drive-by-user) + otherUsername := "user2" + + // Creating a new Wiki page on user2's repo as user1 fails + testCreateWiki := func(expectedStatusCode int) { + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/new", otherUsername, "repo1") + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateWikiPageOptions{ + Title: "Globally Edited Page", + ContentBase64: base64.StdEncoding.EncodeToString([]byte("Wiki page content for API unit tests")), + Message: "", + }).AddTokenAuth(token) + session.MakeRequest(t, req, expectedStatusCode) + } + testCreateWiki(http.StatusForbidden) + + // Update the repo settings for user2's repo to enable globally writeable wiki + ctx := context.Background() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + var units []repo_model.RepoUnit + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeWiki, + Config: new(repo_model.UnitConfig), + DefaultPermissions: repo_model.UnitAccessModeWrite, + }) + err := repo_service.UpdateRepositoryUnits(ctx, repo, units, nil) + require.NoError(t, err) + + // Creating a new Wiki page on user2's repo works now + testCreateWiki(http.StatusCreated) +} + +func TestAPISetWikiGlobalEditability(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // Create a new repository for testing purposes + repo, _, f := tests.CreateDeclarativeRepo(t, user, "", []unit_model.Type{ + unit_model.TypeCode, + unit_model.TypeWiki, + }, nil, nil) + defer f() + urlStr := fmt.Sprintf("/api/v1/repos/%s", repo.FullName()) + + assertGlobalEditability := func(t *testing.T, editability bool) { + t.Helper() + + req := NewRequest(t, "GET", urlStr) + resp := MakeRequest(t, req, http.StatusOK) + + var opts api.Repository + DecodeJSON(t, resp, &opts) + + assert.Equal(t, opts.GloballyEditableWiki, editability) + } + + t.Run("api includes GloballyEditableWiki", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assertGlobalEditability(t, false) + }) + + t.Run("api can turn on GloballyEditableWiki", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + globallyEditable := true + req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditRepoOption{ + GloballyEditableWiki: &globallyEditable, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + assertGlobalEditability(t, true) + }) + + t.Run("disabling the wiki disables GloballyEditableWiki", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + hasWiki := false + req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditRepoOption{ + HasWiki: &hasWiki, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + assertGlobalEditability(t, false) + }) +} + +func TestAPIListPageRevisions(t *testing.T) { + defer tests.PrepareTestEnv(t)() + username := "user2" + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/revisions/Home", username, "repo1") + + req := NewRequest(t, "GET", urlStr) + resp := MakeRequest(t, req, http.StatusOK) + + var revisions *api.WikiCommitList + DecodeJSON(t, resp, &revisions) + + dummyrevisions := &api.WikiCommitList{ + WikiCommits: []*api.WikiCommit{ + { + ID: "2c54faec6c45d31c1abfaecdab471eac6633738a", + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "Ethan Koenig", + Email: "ethantkoenig@gmail.com", + }, + Date: "2017-11-27T04:31:18Z", + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "Ethan Koenig", + Email: "ethantkoenig@gmail.com", + }, + Date: "2017-11-27T04:31:18Z", + }, + Message: "Add Home.md\n", + }, + }, + Count: 1, + } + + assert.Equal(t, dummyrevisions, revisions) +} + +func TestAPIWikiNonMasterBranch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + repo, _, f := tests.CreateDeclarativeRepoWithOptions(t, user, tests.DeclarativeRepoOptions{ + WikiBranch: optional.Some("main"), + }) + defer f() + + uris := []string{ + "revisions/Home", + "pages", + "page/Home", + } + baseURL := fmt.Sprintf("/api/v1/repos/%s/wiki", repo.FullName()) + for _, uri := range uris { + t.Run(uri, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestf(t, "GET", "%s/%s", baseURL, uri) + MakeRequest(t, req, http.StatusOK) + }) + } +} diff --git a/tests/integration/archived_labels_display_test.go b/tests/integration/archived_labels_display_test.go new file mode 100644 index 0000000..c9748f8 --- /dev/null +++ b/tests/integration/archived_labels_display_test.go @@ -0,0 +1,71 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "strings" + "testing" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" +) + +func TestArchivedLabelVisualProperties(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user2") + + // Create labels + session.MakeRequest(t, NewRequestWithValues(t, "POST", "user2/repo1/labels/new", map[string]string{ + "_csrf": GetCSRF(t, session, "user2/repo1/labels"), + "title": "active_label", + "description": "", + "color": "#aa00aa", + }), http.StatusSeeOther) + session.MakeRequest(t, NewRequestWithValues(t, "POST", "user2/repo1/labels/new", map[string]string{ + "_csrf": GetCSRF(t, session, "user2/repo1/labels"), + "title": "archived_label", + "description": "", + "color": "#00aa00", + }), http.StatusSeeOther) + + // Get ID of label to archive it + var id string + doc := NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", "user2/repo1/labels"), http.StatusOK).Body) + doc.Find(".issue-label-list .item").Each(func(i int, s *goquery.Selection) { + label := s.Find(".label-title .label") + if label.Text() == "archived_label" { + href, _ := s.Find(".label-issues a.open-issues").Attr("href") + hrefParts := strings.Split(href, "=") + id = hrefParts[len(hrefParts)-1] + } + }) + + // Make label archived + session.MakeRequest(t, NewRequestWithValues(t, "POST", "user2/repo1/labels/edit", map[string]string{ + "_csrf": GetCSRF(t, session, "user2/repo1/labels"), + "id": id, + "title": "archived_label", + "is_archived": "on", + "description": "", + "color": "#00aa00", + }), http.StatusSeeOther) + + // Test label properties + doc = NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", "user2/repo1/labels"), http.StatusOK).Body) + doc.Find(".issue-label-list .item").Each(func(i int, s *goquery.Selection) { + label := s.Find(".label-title .label") + style, _ := label.Attr("style") + + if label.Text() == "active_label" { + assert.False(t, label.HasClass("archived-label")) + assert.Contains(t, style, "background-color: #aa00aaff") + } else if label.Text() == "archived_label" { + assert.True(t, label.HasClass("archived-label")) + assert.Contains(t, style, "background-color: #00aa007f") + } + }) + }) +} diff --git a/tests/integration/attachment_test.go b/tests/integration/attachment_test.go new file mode 100644 index 0000000..7cbc254 --- /dev/null +++ b/tests/integration/attachment_test.go @@ -0,0 +1,138 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "image" + "image/png" + "io" + "mime/multipart" + "net/http" + "strings" + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func generateImg() bytes.Buffer { + // Generate image + myImage := image.NewRGBA(image.Rect(0, 0, 32, 32)) + var buff bytes.Buffer + png.Encode(&buff, myImage) + return buff +} + +func createAttachment(t *testing.T, session *TestSession, repoURL, filename string, buff bytes.Buffer, expectedStatus int) string { + body := &bytes.Buffer{} + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("file", filename) + require.NoError(t, err) + _, err = io.Copy(part, &buff) + require.NoError(t, err) + err = writer.Close() + require.NoError(t, err) + + csrf := GetCSRF(t, session, repoURL) + + req := NewRequestWithBody(t, "POST", repoURL+"/issues/attachments", body) + req.Header.Add("X-Csrf-Token", csrf) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, expectedStatus) + + if expectedStatus != http.StatusOK { + return "" + } + var obj map[string]string + DecodeJSON(t, resp, &obj) + return obj["uuid"] +} + +func TestCreateAnonymousAttachment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := emptyTestSession(t) + // this test is not right because it just doesn't pass the CSRF validation + createAttachment(t, session, "user2/repo1", "image.png", generateImg(), http.StatusBadRequest) +} + +func TestCreateIssueAttachment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + const repoURL = "user2/repo1" + session := loginUser(t, "user2") + uuid := createAttachment(t, session, repoURL, "image.png", generateImg(), http.StatusOK) + + req := NewRequest(t, "GET", repoURL+"/issues/new") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + link, exists := htmlDoc.doc.Find("form#new-issue").Attr("action") + assert.True(t, exists, "The template has changed") + + postData := map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "title": "New Issue With Attachment", + "content": "some content", + "files": uuid, + } + + req = NewRequestWithValues(t, "POST", link, postData) + resp = session.MakeRequest(t, req, http.StatusOK) + test.RedirectURL(resp) // check that redirect URL exists + + // Validate that attachment is available + req = NewRequest(t, "GET", "/attachments/"+uuid) + session.MakeRequest(t, req, http.StatusOK) + + // anonymous visit should be allowed because user2/repo1 is a public repository + MakeRequest(t, req, http.StatusOK) +} + +func TestGetAttachment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + adminSession := loginUser(t, "user1") + user2Session := loginUser(t, "user2") + user8Session := loginUser(t, "user8") + emptySession := emptyTestSession(t) + testCases := []struct { + name string + uuid string + createFile bool + session *TestSession + want int + }{ + {"LinkedIssueUUID", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", true, user2Session, http.StatusOK}, + {"LinkedCommentUUID", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a17", true, user2Session, http.StatusOK}, + {"linked_release_uuid", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a19", true, user2Session, http.StatusOK}, + {"NotExistingUUID", "b0eebc99-9c0b-4ef8-bb6d-6bb9bd380a18", false, user2Session, http.StatusNotFound}, + {"FileMissing", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a18", false, user2Session, http.StatusInternalServerError}, + {"NotLinked", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a20", true, user2Session, http.StatusNotFound}, + {"NotLinkedAccessibleByUploader", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a20", true, user8Session, http.StatusOK}, + {"PublicByNonLogged", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", true, emptySession, http.StatusOK}, + {"PrivateByNonLogged", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, emptySession, http.StatusNotFound}, + {"PrivateAccessibleByAdmin", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, adminSession, http.StatusOK}, + {"PrivateAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user2Session, http.StatusOK}, + {"RepoNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user8Session, http.StatusNotFound}, + {"OrgNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a21", true, user8Session, http.StatusNotFound}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Write empty file to be available for response + if tc.createFile { + _, err := storage.Attachments.Save(repo_model.AttachmentRelativePath(tc.uuid), strings.NewReader("hello world"), -1) + require.NoError(t, err) + } + // Actual test + req := NewRequest(t, "GET", "/attachments/"+tc.uuid) + tc.session.MakeRequest(t, req, tc.want) + }) + } +} diff --git a/tests/integration/auth_ldap_test.go b/tests/integration/auth_ldap_test.go new file mode 100644 index 0000000..9bcb532 --- /dev/null +++ b/tests/integration/auth_ldap_test.go @@ -0,0 +1,565 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "net/http" + "os" + "strings" + "testing" + + "code.gitea.io/gitea/models" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/auth/source/ldap" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type ldapUser struct { + UserName string + Password string + FullName string + Email string + OtherEmails []string + IsAdmin bool + IsRestricted bool + SSHKeys []string +} + +var gitLDAPUsers = []ldapUser{ + { + UserName: "professor", + Password: "professor", + FullName: "Hubert Farnsworth", + Email: "professor@planetexpress.com", + OtherEmails: []string{"hubert@planetexpress.com"}, + IsAdmin: true, + }, + { + UserName: "hermes", + Password: "hermes", + FullName: "Conrad Hermes", + Email: "hermes@planetexpress.com", + SSHKeys: []string{ + "SHA256:qLY06smKfHoW/92yXySpnxFR10QFrLdRjf/GNPvwcW8", + "SHA256:QlVTuM5OssDatqidn2ffY+Lc4YA5Fs78U+0KOHI51jQ", + "SHA256:DXdeUKYOJCSSmClZuwrb60hUq7367j4fA+udNC3FdRI", + }, + IsAdmin: true, + }, + { + UserName: "fry", + Password: "fry", + FullName: "Philip Fry", + Email: "fry@planetexpress.com", + }, + { + UserName: "leela", + Password: "leela", + FullName: "Leela Turanga", + Email: "leela@planetexpress.com", + IsRestricted: true, + }, + { + UserName: "bender", + Password: "bender", + FullName: "Bender Rodríguez", + Email: "bender@planetexpress.com", + }, +} + +var otherLDAPUsers = []ldapUser{ + { + UserName: "zoidberg", + Password: "zoidberg", + FullName: "John Zoidberg", + Email: "zoidberg@planetexpress.com", + }, + { + UserName: "amy", + Password: "amy", + FullName: "Amy Kroker", + Email: "amy@planetexpress.com", + }, +} + +func skipLDAPTests() bool { + return os.Getenv("TEST_LDAP") != "1" +} + +func getLDAPServerHost() string { + host := os.Getenv("TEST_LDAP_HOST") + if len(host) == 0 { + host = "ldap" + } + return host +} + +func getLDAPServerPort() string { + port := os.Getenv("TEST_LDAP_PORT") + if len(port) == 0 { + port = "389" + } + return port +} + +func buildAuthSourceLDAPPayload(csrf, sshKeyAttribute, mailKeyAttribute, defaultDomainName, groupFilter, groupTeamMap, groupTeamMapRemoval string) map[string]string { + // Modify user filter to test group filter explicitly + userFilter := "(&(objectClass=inetOrgPerson)(memberOf=cn=git,ou=people,dc=planetexpress,dc=com)(uid=%s))" + if groupFilter != "" { + userFilter = "(&(objectClass=inetOrgPerson)(uid=%s))" + } + + if len(mailKeyAttribute) == 0 { + mailKeyAttribute = "mail" + } + + return map[string]string{ + "_csrf": csrf, + "type": "2", + "name": "ldap", + "host": getLDAPServerHost(), + "port": getLDAPServerPort(), + "bind_dn": "uid=gitea,ou=service,dc=planetexpress,dc=com", + "bind_password": "password", + "user_base": "ou=people,dc=planetexpress,dc=com", + "filter": userFilter, + "admin_filter": "(memberOf=cn=admin_staff,ou=people,dc=planetexpress,dc=com)", + "restricted_filter": "(uid=leela)", + "attribute_username": "uid", + "attribute_name": "givenName", + "attribute_surname": "sn", + "attribute_mail": mailKeyAttribute, + "attribute_ssh_public_key": sshKeyAttribute, + "default_domain_name": defaultDomainName, + "is_sync_enabled": "on", + "is_active": "on", + "groups_enabled": "on", + "group_dn": "ou=people,dc=planetexpress,dc=com", + "group_member_uid": "member", + "group_filter": groupFilter, + "group_team_map": groupTeamMap, + "group_team_map_removal": groupTeamMapRemoval, + "user_uid": "DN", + } +} + +func addAuthSourceLDAP(t *testing.T, sshKeyAttribute, mailKeyAttribute, defaultDomainName, groupFilter string, groupMapParams ...string) { + groupTeamMapRemoval := "off" + groupTeamMap := "" + if len(groupMapParams) == 2 { + groupTeamMapRemoval = groupMapParams[0] + groupTeamMap = groupMapParams[1] + } + session := loginUser(t, "user1") + csrf := GetCSRF(t, session, "/admin/auths/new") + req := NewRequestWithValues(t, "POST", "/admin/auths/new", buildAuthSourceLDAPPayload(csrf, sshKeyAttribute, mailKeyAttribute, defaultDomainName, groupFilter, groupTeamMap, groupTeamMapRemoval)) + session.MakeRequest(t, req, http.StatusSeeOther) +} + +func TestLDAPUserSignin(t *testing.T) { + if skipLDAPTests() { + t.Skip() + return + } + defer tests.PrepareTestEnv(t)() + addAuthSourceLDAP(t, "", "", "", "") + + u := gitLDAPUsers[0] + + session := loginUserWithPassword(t, u.UserName, u.Password) + req := NewRequest(t, "GET", "/user/settings") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + assert.Equal(t, u.UserName, htmlDoc.GetInputValueByName("name")) + assert.Equal(t, u.FullName, htmlDoc.GetInputValueByName("full_name")) + assert.Equal(t, u.Email, htmlDoc.Find("#signed-user-email").Text()) +} + +func TestLDAPAuthChange(t *testing.T) { + defer tests.PrepareTestEnv(t)() + addAuthSourceLDAP(t, "", "", "", "") + + session := loginUser(t, "user1") + req := NewRequest(t, "GET", "/admin/auths") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + href, exists := doc.Find("table.table td a").Attr("href") + if !exists { + assert.True(t, exists, "No authentication source found") + return + } + + req = NewRequest(t, "GET", href) + resp = session.MakeRequest(t, req, http.StatusOK) + doc = NewHTMLParser(t, resp.Body) + csrf := doc.GetCSRF() + host, _ := doc.Find(`input[name="host"]`).Attr("value") + assert.Equal(t, host, getLDAPServerHost()) + binddn, _ := doc.Find(`input[name="bind_dn"]`).Attr("value") + assert.Equal(t, "uid=gitea,ou=service,dc=planetexpress,dc=com", binddn) + + req = NewRequestWithValues(t, "POST", href, buildAuthSourceLDAPPayload(csrf, "", "", "", "", "", "off")) + session.MakeRequest(t, req, http.StatusSeeOther) + + req = NewRequest(t, "GET", href) + resp = session.MakeRequest(t, req, http.StatusOK) + doc = NewHTMLParser(t, resp.Body) + host, _ = doc.Find(`input[name="host"]`).Attr("value") + assert.Equal(t, host, getLDAPServerHost()) + binddn, _ = doc.Find(`input[name="bind_dn"]`).Attr("value") + assert.Equal(t, "uid=gitea,ou=service,dc=planetexpress,dc=com", binddn) + domainname, _ := doc.Find(`input[name="default_domain_name"]`).Attr("value") + assert.Equal(t, "", domainname) + + req = NewRequestWithValues(t, "POST", href, buildAuthSourceLDAPPayload(csrf, "", "", "test.org", "", "", "off")) + session.MakeRequest(t, req, http.StatusSeeOther) + + req = NewRequest(t, "GET", href) + resp = session.MakeRequest(t, req, http.StatusOK) + doc = NewHTMLParser(t, resp.Body) + host, _ = doc.Find(`input[name="host"]`).Attr("value") + assert.Equal(t, host, getLDAPServerHost()) + binddn, _ = doc.Find(`input[name="bind_dn"]`).Attr("value") + assert.Equal(t, "uid=gitea,ou=service,dc=planetexpress,dc=com", binddn) + domainname, _ = doc.Find(`input[name="default_domain_name"]`).Attr("value") + assert.Equal(t, "test.org", domainname) +} + +func TestLDAPUserSync(t *testing.T) { + if skipLDAPTests() { + t.Skip() + return + } + defer tests.PrepareTestEnv(t)() + addAuthSourceLDAP(t, "", "", "", "") + auth.SyncExternalUsers(context.Background(), true) + + // Check if users exists + for _, gitLDAPUser := range gitLDAPUsers { + dbUser, err := user_model.GetUserByName(db.DefaultContext, gitLDAPUser.UserName) + require.NoError(t, err) + assert.Equal(t, gitLDAPUser.UserName, dbUser.Name) + assert.Equal(t, gitLDAPUser.Email, dbUser.Email) + assert.Equal(t, gitLDAPUser.IsAdmin, dbUser.IsAdmin) + assert.Equal(t, gitLDAPUser.IsRestricted, dbUser.IsRestricted) + } + + // Check if no users exist + for _, otherLDAPUser := range otherLDAPUsers { + _, err := user_model.GetUserByName(db.DefaultContext, otherLDAPUser.UserName) + assert.True(t, user_model.IsErrUserNotExist(err)) + } +} + +func TestLDAPUserSyncWithEmptyUsernameAttribute(t *testing.T) { + if skipLDAPTests() { + t.Skip() + return + } + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + csrf := GetCSRF(t, session, "/admin/auths/new") + payload := buildAuthSourceLDAPPayload(csrf, "", "", "", "", "", "") + payload["attribute_username"] = "" + req := NewRequestWithValues(t, "POST", "/admin/auths/new", payload) + session.MakeRequest(t, req, http.StatusSeeOther) + + for _, u := range gitLDAPUsers { + req := NewRequest(t, "GET", "/admin/users?q="+u.UserName) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + tr := htmlDoc.doc.Find("table.table tbody tr") + assert.Equal(t, 0, tr.Length()) + } + + for _, u := range gitLDAPUsers { + req := NewRequestWithValues(t, "POST", "/user/login", map[string]string{ + "_csrf": csrf, + "user_name": u.UserName, + "password": u.Password, + }) + MakeRequest(t, req, http.StatusSeeOther) + } + + auth.SyncExternalUsers(context.Background(), true) + + authSource := unittest.AssertExistsAndLoadBean(t, &auth_model.Source{ + Name: payload["name"], + }) + unittest.AssertCount(t, &user_model.User{ + LoginType: auth_model.LDAP, + LoginSource: authSource.ID, + }, len(gitLDAPUsers)) + + for _, u := range gitLDAPUsers { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + Name: u.UserName, + }) + assert.True(t, user.IsActive) + } +} + +func TestLDAPUserSyncWithGroupFilter(t *testing.T) { + if skipLDAPTests() { + t.Skip() + return + } + defer tests.PrepareTestEnv(t)() + addAuthSourceLDAP(t, "", "", "", "(cn=git)") + + // Assert a user not a member of the LDAP group "cn=git" cannot login + // This test may look like TestLDAPUserSigninFailed but it is not. + // The later test uses user filter containing group membership filter (memberOf) + // This test is for the case when LDAP user records may not be linked with + // all groups the user is a member of, the user filter is modified accordingly inside + // the addAuthSourceLDAP based on the value of the groupFilter + u := otherLDAPUsers[0] + testLoginFailed(t, u.UserName, u.Password, translation.NewLocale("en-US").TrString("form.username_password_incorrect")) + + auth.SyncExternalUsers(context.Background(), true) + + // Assert members of LDAP group "cn=git" are added + for _, gitLDAPUser := range gitLDAPUsers { + unittest.BeanExists(t, &user_model.User{ + Name: gitLDAPUser.UserName, + }) + } + + // Assert everyone else is not added + for _, gitLDAPUser := range otherLDAPUsers { + unittest.AssertNotExistsBean(t, &user_model.User{ + Name: gitLDAPUser.UserName, + }) + } + + ldapSource := unittest.AssertExistsAndLoadBean(t, &auth_model.Source{ + Name: "ldap", + }) + ldapConfig := ldapSource.Cfg.(*ldap.Source) + ldapConfig.GroupFilter = "(cn=ship_crew)" + auth_model.UpdateSource(db.DefaultContext, ldapSource) + + auth.SyncExternalUsers(context.Background(), true) + + for _, gitLDAPUser := range gitLDAPUsers { + if gitLDAPUser.UserName == "fry" || gitLDAPUser.UserName == "leela" || gitLDAPUser.UserName == "bender" { + // Assert members of the LDAP group "cn-ship_crew" are still active + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + Name: gitLDAPUser.UserName, + }) + assert.True(t, user.IsActive, "User %s should be active", gitLDAPUser.UserName) + } else { + // Assert everyone else is inactive + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + Name: gitLDAPUser.UserName, + }) + assert.False(t, user.IsActive, "User %s should be inactive", gitLDAPUser.UserName) + } + } +} + +func TestLDAPUserSigninFailed(t *testing.T) { + if skipLDAPTests() { + t.Skip() + return + } + defer tests.PrepareTestEnv(t)() + addAuthSourceLDAP(t, "", "", "", "") + + u := otherLDAPUsers[0] + testLoginFailed(t, u.UserName, u.Password, translation.NewLocale("en-US").TrString("form.username_password_incorrect")) +} + +func TestLDAPUserSSHKeySync(t *testing.T) { + if skipLDAPTests() { + t.Skip() + return + } + defer tests.PrepareTestEnv(t)() + addAuthSourceLDAP(t, "sshPublicKey", "", "", "") + + auth.SyncExternalUsers(context.Background(), true) + + // Check if users has SSH keys synced + for _, u := range gitLDAPUsers { + if len(u.SSHKeys) == 0 { + continue + } + session := loginUserWithPassword(t, u.UserName, u.Password) + + req := NewRequest(t, "GET", "/user/settings/keys") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + divs := htmlDoc.doc.Find("#keys-ssh .flex-item .flex-item-body:not(:last-child)") + + syncedKeys := make([]string, divs.Length()) + for i := 0; i < divs.Length(); i++ { + syncedKeys[i] = strings.TrimSpace(divs.Eq(i).Text()) + } + + assert.ElementsMatch(t, u.SSHKeys, syncedKeys, "Unequal number of keys synchronized for user: %s", u.UserName) + } +} + +func TestLDAPGroupTeamSyncAddMember(t *testing.T) { + if skipLDAPTests() { + t.Skip() + return + } + defer tests.PrepareTestEnv(t)() + addAuthSourceLDAP(t, "", "", "", "", "on", `{"cn=ship_crew,ou=people,dc=planetexpress,dc=com":{"org26": ["team11"]},"cn=admin_staff,ou=people,dc=planetexpress,dc=com": {"non-existent": ["non-existent"]}}`) + org, err := organization.GetOrgByName(db.DefaultContext, "org26") + require.NoError(t, err) + team, err := organization.GetTeam(db.DefaultContext, org.ID, "team11") + require.NoError(t, err) + auth.SyncExternalUsers(context.Background(), true) + for _, gitLDAPUser := range gitLDAPUsers { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + Name: gitLDAPUser.UserName, + }) + usersOrgs, err := db.Find[organization.Organization](db.DefaultContext, organization.FindOrgOptions{ + UserID: user.ID, + IncludePrivate: true, + }) + require.NoError(t, err) + allOrgTeams, err := organization.GetUserOrgTeams(db.DefaultContext, org.ID, user.ID) + require.NoError(t, err) + if user.Name == "fry" || user.Name == "leela" || user.Name == "bender" { + // assert members of LDAP group "cn=ship_crew" are added to mapped teams + assert.Len(t, usersOrgs, 1, "User [%s] should be member of one organization", user.Name) + assert.Equal(t, "org26", usersOrgs[0].Name, "Membership should be added to the right organization") + isMember, err := organization.IsTeamMember(db.DefaultContext, usersOrgs[0].ID, team.ID, user.ID) + require.NoError(t, err) + assert.True(t, isMember, "Membership should be added to the right team") + err = models.RemoveTeamMember(db.DefaultContext, team, user.ID) + require.NoError(t, err) + err = models.RemoveOrgUser(db.DefaultContext, usersOrgs[0].ID, user.ID) + require.NoError(t, err) + } else { + // assert members of LDAP group "cn=admin_staff" keep initial team membership since mapped team does not exist + assert.Empty(t, usersOrgs, "User should be member of no organization") + isMember, err := organization.IsTeamMember(db.DefaultContext, org.ID, team.ID, user.ID) + require.NoError(t, err) + assert.False(t, isMember, "User should no be added to this team") + assert.Empty(t, allOrgTeams, "User should not be added to any team") + } + } +} + +func TestLDAPGroupTeamSyncRemoveMember(t *testing.T) { + if skipLDAPTests() { + t.Skip() + return + } + defer tests.PrepareTestEnv(t)() + addAuthSourceLDAP(t, "", "", "", "", "on", `{"cn=dispatch,ou=people,dc=planetexpress,dc=com": {"org26": ["team11"]}}`) + org, err := organization.GetOrgByName(db.DefaultContext, "org26") + require.NoError(t, err) + team, err := organization.GetTeam(db.DefaultContext, org.ID, "team11") + require.NoError(t, err) + loginUserWithPassword(t, gitLDAPUsers[0].UserName, gitLDAPUsers[0].Password) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + Name: gitLDAPUsers[0].UserName, + }) + err = organization.AddOrgUser(db.DefaultContext, org.ID, user.ID) + require.NoError(t, err) + err = models.AddTeamMember(db.DefaultContext, team, user.ID) + require.NoError(t, err) + isMember, err := organization.IsOrganizationMember(db.DefaultContext, org.ID, user.ID) + require.NoError(t, err) + assert.True(t, isMember, "User should be member of this organization") + isMember, err = organization.IsTeamMember(db.DefaultContext, org.ID, team.ID, user.ID) + require.NoError(t, err) + assert.True(t, isMember, "User should be member of this team") + // assert team member "professor" gets removed from org26 team11 + loginUserWithPassword(t, gitLDAPUsers[0].UserName, gitLDAPUsers[0].Password) + isMember, err = organization.IsOrganizationMember(db.DefaultContext, org.ID, user.ID) + require.NoError(t, err) + assert.False(t, isMember, "User membership should have been removed from organization") + isMember, err = organization.IsTeamMember(db.DefaultContext, org.ID, team.ID, user.ID) + require.NoError(t, err) + assert.False(t, isMember, "User membership should have been removed from team") +} + +func TestLDAPPreventInvalidGroupTeamMap(t *testing.T) { + if skipLDAPTests() { + t.Skip() + return + } + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + csrf := GetCSRF(t, session, "/admin/auths/new") + req := NewRequestWithValues(t, "POST", "/admin/auths/new", buildAuthSourceLDAPPayload(csrf, "", "", "", "", `{"NOT_A_VALID_JSON"["MISSING_DOUBLE_POINT"]}`, "off")) + session.MakeRequest(t, req, http.StatusOK) // StatusOK = failed, StatusSeeOther = ok +} + +func TestLDAPUserSyncInvalidMail(t *testing.T) { + if skipLDAPTests() { + t.Skip() + return + } + defer tests.PrepareTestEnv(t)() + addAuthSourceLDAP(t, "", "nonexisting", "", "") + auth.SyncExternalUsers(context.Background(), true) + + // Check if users exists + for _, gitLDAPUser := range gitLDAPUsers { + dbUser, err := user_model.GetUserByName(db.DefaultContext, gitLDAPUser.UserName) + require.NoError(t, err) + assert.Equal(t, gitLDAPUser.UserName, dbUser.Name) + assert.Equal(t, gitLDAPUser.UserName+"@localhost.local", dbUser.Email) + assert.Equal(t, gitLDAPUser.IsAdmin, dbUser.IsAdmin) + assert.Equal(t, gitLDAPUser.IsRestricted, dbUser.IsRestricted) + } + + // Check if no users exist + for _, otherLDAPUser := range otherLDAPUsers { + _, err := user_model.GetUserByName(db.DefaultContext, otherLDAPUser.UserName) + assert.True(t, user_model.IsErrUserNotExist(err)) + } +} + +func TestLDAPUserSyncInvalidMailDefaultDomain(t *testing.T) { + if skipLDAPTests() { + t.Skip() + return + } + defer tests.PrepareTestEnv(t)() + addAuthSourceLDAP(t, "", "nonexisting", "test.org", "") + auth.SyncExternalUsers(context.Background(), true) + + // Check if users exists + for _, gitLDAPUser := range gitLDAPUsers { + dbUser, err := user_model.GetUserByName(db.DefaultContext, gitLDAPUser.UserName) + require.NoError(t, err) + assert.Equal(t, gitLDAPUser.UserName, dbUser.Name) + assert.Equal(t, gitLDAPUser.UserName+"@test.org", dbUser.Email) + assert.Equal(t, gitLDAPUser.IsAdmin, dbUser.IsAdmin) + assert.Equal(t, gitLDAPUser.IsRestricted, dbUser.IsRestricted) + } + + // Check if no users exist + for _, otherLDAPUser := range otherLDAPUsers { + _, err := user_model.GetUserByName(db.DefaultContext, otherLDAPUser.UserName) + assert.True(t, user_model.IsErrUserNotExist(err)) + } +} diff --git a/tests/integration/auth_token_test.go b/tests/integration/auth_token_test.go new file mode 100644 index 0000000..d1fd5dd --- /dev/null +++ b/tests/integration/auth_token_test.go @@ -0,0 +1,164 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/hex" + "net/http" + "net/url" + "strings" + "testing" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// GetSessionForLTACookie returns a new session with only the LTA cookie being set. +func GetSessionForLTACookie(t *testing.T, ltaCookie *http.Cookie) *TestSession { + t.Helper() + + ch := http.Header{} + ch.Add("Cookie", ltaCookie.String()) + cr := http.Request{Header: ch} + + session := emptyTestSession(t) + baseURL, err := url.Parse(setting.AppURL) + require.NoError(t, err) + session.jar.SetCookies(baseURL, cr.Cookies()) + + return session +} + +// GetLTACookieValue returns the value of the LTA cookie. +func GetLTACookieValue(t *testing.T, sess *TestSession) string { + t.Helper() + + rememberCookie := sess.GetCookie(setting.CookieRememberName) + assert.NotNil(t, rememberCookie) + + cookieValue, err := url.QueryUnescape(rememberCookie.Value) + require.NoError(t, err) + + return cookieValue +} + +// TestSessionCookie checks if the session cookie provides authentication. +func TestSessionCookie(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + sess := loginUser(t, "user1") + assert.NotNil(t, sess.GetCookie(setting.SessionConfig.CookieName)) + + req := NewRequest(t, "GET", "/user/settings") + sess.MakeRequest(t, req, http.StatusOK) +} + +// TestLTACookie checks if the LTA cookie that's returned is valid, exists in the database +// and provides authentication of no session cookie is present. +func TestLTACookie(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + sess := emptyTestSession(t) + + req := NewRequestWithValues(t, "POST", "/user/login", map[string]string{ + "_csrf": GetCSRF(t, sess, "/user/login"), + "user_name": user.Name, + "password": userPassword, + "remember": "true", + }) + sess.MakeRequest(t, req, http.StatusSeeOther) + + // Checks if the database entry exist for the user. + ltaCookieValue := GetLTACookieValue(t, sess) + lookupKey, validator, found := strings.Cut(ltaCookieValue, ":") + assert.True(t, found) + rawValidator, err := hex.DecodeString(validator) + require.NoError(t, err) + unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{LookupKey: lookupKey, HashedValidator: auth.HashValidator(rawValidator), UID: user.ID, Purpose: auth.LongTermAuthorization}) + + // Check if the LTA cookie it provides authentication. + // If LTA cookie provides authentication /user/login shouldn't return status 200. + session := GetSessionForLTACookie(t, sess.GetCookie(setting.CookieRememberName)) + req = NewRequest(t, "GET", "/user/login") + session.MakeRequest(t, req, http.StatusSeeOther) +} + +// TestLTAPasswordChange checks that LTA doesn't provide authentication when a +// password change has happened and that the new LTA does provide authentication. +func TestLTAPasswordChange(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + sess := loginUserWithPasswordRemember(t, user.Name, userPassword, true) + oldRememberCookie := sess.GetCookie(setting.CookieRememberName) + assert.NotNil(t, oldRememberCookie) + + // Make a simple password change. + req := NewRequestWithValues(t, "POST", "/user/settings/account", map[string]string{ + "_csrf": GetCSRF(t, sess, "/user/settings/account"), + "old_password": userPassword, + "password": "password2", + "retype": "password2", + }) + sess.MakeRequest(t, req, http.StatusSeeOther) + rememberCookie := sess.GetCookie(setting.CookieRememberName) + assert.NotNil(t, rememberCookie) + + // Check if the password really changed. + assert.NotEqualValues(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).Passwd, user.Passwd) + + // /user/settings/account should provide with a new LTA cookie, so check for that. + // If LTA cookie provides authentication /user/login shouldn't return status 200. + session := GetSessionForLTACookie(t, rememberCookie) + req = NewRequest(t, "GET", "/user/login") + session.MakeRequest(t, req, http.StatusSeeOther) + + // Check if the old LTA token is invalidated. + session = GetSessionForLTACookie(t, oldRememberCookie) + req = NewRequest(t, "GET", "/user/login") + session.MakeRequest(t, req, http.StatusOK) +} + +// TestLTAExpiry tests that the LTA expiry works. +func TestLTAExpiry(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + sess := loginUserWithPasswordRemember(t, user.Name, userPassword, true) + + ltaCookieValie := GetLTACookieValue(t, sess) + lookupKey, _, found := strings.Cut(ltaCookieValie, ":") + assert.True(t, found) + + // Ensure it's not expired. + lta := unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey, Purpose: auth.LongTermAuthorization}) + assert.False(t, lta.IsExpired()) + + // Manually stub LTA's expiry. + _, err := db.GetEngine(db.DefaultContext).ID(lta.ID).Table("forgejo_auth_token").Cols("expiry").Update(&auth.AuthorizationToken{Expiry: timeutil.TimeStampNow()}) + require.NoError(t, err) + + // Ensure it's expired. + lta = unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey, Purpose: auth.LongTermAuthorization}) + assert.True(t, lta.IsExpired()) + + // Should return 200 OK, because LTA doesn't provide authorization anymore. + session := GetSessionForLTACookie(t, sess.GetCookie(setting.CookieRememberName)) + req := NewRequest(t, "GET", "/user/login") + session.MakeRequest(t, req, http.StatusOK) + + // Ensure it's deleted. + unittest.AssertNotExistsBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey, Purpose: auth.LongTermAuthorization}) +} diff --git a/tests/integration/avatar.png b/tests/integration/avatar.png Binary files differnew file mode 100644 index 0000000..dfd2125 --- /dev/null +++ b/tests/integration/avatar.png diff --git a/tests/integration/benchmarks_test.go b/tests/integration/benchmarks_test.go new file mode 100644 index 0000000..62da761 --- /dev/null +++ b/tests/integration/benchmarks_test.go @@ -0,0 +1,69 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "math/rand/v2" + "net/http" + "net/url" + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + api "code.gitea.io/gitea/modules/structs" +) + +// StringWithCharset random string (from https://www.calhoun.io/creating-random-strings-in-go/) +func StringWithCharset(length int, charset string) string { + b := make([]byte, length) + for i := range b { + b[i] = charset[rand.IntN(len(charset))] + } + return string(b) +} + +func BenchmarkRepoBranchCommit(b *testing.B) { + onGiteaRun(b, func(b *testing.B, u *url.URL) { + samples := []int64{1, 2, 3} + b.ResetTimer() + + for _, repoID := range samples { + b.StopTimer() + repo := unittest.AssertExistsAndLoadBean(b, &repo_model.Repository{ID: repoID}) + b.StartTimer() + b.Run(repo.Name, func(b *testing.B) { + session := loginUser(b, "user2") + b.ResetTimer() + b.Run("CreateBranch", func(b *testing.B) { + b.StopTimer() + branchName := StringWithCharset(5+rand.IntN(10), "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + b.StartTimer() + for i := 0; i < b.N; i++ { + b.Run("new_"+branchName, func(b *testing.B) { + b.Skip("benchmark broken") // TODO fix + testAPICreateBranch(b, session, repo.OwnerName, repo.Name, repo.DefaultBranch, "new_"+branchName, http.StatusCreated) + }) + } + }) + b.Run("GetBranches", func(b *testing.B) { + req := NewRequestf(b, "GET", "/api/v1/repos/%s/branches", repo.FullName()) + session.MakeRequest(b, req, http.StatusOK) + }) + b.Run("AccessCommits", func(b *testing.B) { + var branches []*api.Branch + req := NewRequestf(b, "GET", "/api/v1/repos/%s/branches", repo.FullName()) + resp := session.MakeRequest(b, req, http.StatusOK) + DecodeJSON(b, resp, &branches) + b.ResetTimer() // We measure from here + if len(branches) != 0 { + for i := 0; i < b.N; i++ { + req := NewRequestf(b, "GET", "/api/v1/repos/%s/commits?sha=%s", repo.FullName(), branches[i%len(branches)].Name) + session.MakeRequest(b, req, http.StatusOK) + } + } + }) + }) + } + }) +} diff --git a/tests/integration/block_test.go b/tests/integration/block_test.go new file mode 100644 index 0000000..b17a445 --- /dev/null +++ b/tests/integration/block_test.go @@ -0,0 +1,454 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "path" + "strconv" + "testing" + + "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + issue_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/translation" + forgejo_context "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func BlockUser(t *testing.T, doer, blockedUser *user_model.User) { + t.Helper() + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID}) + + session := loginUser(t, doer.Name) + req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{ + "_csrf": GetCSRF(t, session, "/"+blockedUser.Name), + "action": "block", + }) + session.MakeRequest(t, req, http.StatusOK) + + assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID})) +} + +// TestBlockUser ensures that users can execute blocking related actions can +// happen under the correct conditions. +func TestBlockUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8}) + blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + session := loginUser(t, doer.Name) + + t.Run("Block", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + BlockUser(t, doer, blockedUser) + }) + + // Unblock user. + t.Run("Unblock", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{ + "_csrf": GetCSRF(t, session, "/"+blockedUser.Name), + "action": "unblock", + }) + session.MakeRequest(t, req, http.StatusOK) + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID}) + }) + + t.Run("Organization as target", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + targetOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization}) + + t.Run("Block", func(t *testing.T) { + req := NewRequestWithValues(t, "POST", "/"+targetOrg.Name, map[string]string{ + "_csrf": GetCSRF(t, session, "/"+targetOrg.Name), + "action": "block", + }) + resp := session.MakeRequest(t, req, http.StatusBadRequest) + + assert.Contains(t, resp.Body.String(), "Action \\\"block\\\" failed") + }) + + t.Run("Unblock", func(t *testing.T) { + req := NewRequestWithValues(t, "POST", "/"+targetOrg.Name, map[string]string{ + "_csrf": GetCSRF(t, session, "/"+targetOrg.Name), + "action": "unblock", + }) + resp := session.MakeRequest(t, req, http.StatusBadRequest) + + assert.Contains(t, resp.Body.String(), "Action \\\"unblock\\\" failed") + }) + }) +} + +// TestBlockUserFromOrganization ensures that an organisation can block and unblock an user. +func TestBlockUserFromOrganization(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15}) + blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17, Type: user_model.UserTypeOrganization}) + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID}) + session := loginUser(t, doer.Name) + + t.Run("Block user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/block", map[string]string{ + "_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"), + "uname": blockedUser.Name, + }) + session.MakeRequest(t, req, http.StatusSeeOther) + assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID})) + }) + + t.Run("Unblock user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/unblock", map[string]string{ + "_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"), + "user_id": strconv.FormatInt(blockedUser.ID, 10), + }) + session.MakeRequest(t, req, http.StatusSeeOther) + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID}) + }) + + t.Run("Organization as target", func(t *testing.T) { + targetOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization}) + + t.Run("Block", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/block", map[string]string{ + "_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"), + "uname": targetOrg.Name, + }) + session.MakeRequest(t, req, http.StatusInternalServerError) + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: targetOrg.ID}) + }) + + t.Run("Unblock", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/unblock", map[string]string{ + "_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"), + "user_id": strconv.FormatInt(targetOrg.ID, 10), + }) + session.MakeRequest(t, req, http.StatusInternalServerError) + }) + }) +} + +// TestBlockActions ensures that certain actions cannot be performed as a doer +// and as a blocked user and are handled cleanly after the blocking has taken +// place. +func TestBlockActions(t *testing.T) { + defer tests.AddFixtures("tests/integration/fixtures/TestBlockActions/")() + defer tests.PrepareTestEnv(t)() + + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + blockedUser2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10}) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: doer.ID}) + repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: doer.ID}) + repo7 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 7, OwnerID: blockedUser2.ID}) + issue4 := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 4, RepoID: repo2.ID}) + issue4URL := fmt.Sprintf("/%s/issues/%d", repo2.FullName(), issue4.Index) + repo42 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 42, OwnerID: doer.ID}) + issue10 := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 10, RepoID: repo42.ID}, unittest.Cond("poster_id != ?", doer.ID)) + issue10URL := fmt.Sprintf("/%s/issues/%d", repo42.FullName(), issue10.Index) + // NOTE: Sessions shouldn't be shared, because in some situations flash + // messages are persistent and that would interfere with accurate test + // results. + + BlockUser(t, doer, blockedUser) + BlockUser(t, doer, blockedUser2) + + type errorJSON struct { + Error string `json:"errorMessage"` + } + locale := translation.NewLocale("en-US") + + // Ensures that issue creation on doer's owned repositories are blocked. + t.Run("Issue creation", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, blockedUser.Name) + link := fmt.Sprintf("%s/issues/new", repo2.FullName()) + + req := NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": GetCSRF(t, session, link), + "title": "Title", + "content": "Hello!", + }) + resp := session.MakeRequest(t, req, http.StatusBadRequest) + + var errorResp errorJSON + DecodeJSON(t, resp, &errorResp) + + assert.EqualValues(t, locale.Tr("repo.issues.blocked_by_user"), errorResp.Error) + }) + + // Ensures that pull creation on doer's owned repositories are blocked. + t.Run("Pull creation", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, blockedUser.Name) + link := fmt.Sprintf("%s/compare/v1.1...master", repo1.FullName()) + + req := NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": GetCSRF(t, session, link), + "title": "Title", + "content": "Hello!", + }) + resp := session.MakeRequest(t, req, http.StatusBadRequest) + + var errorResp errorJSON + DecodeJSON(t, resp, &errorResp) + + assert.EqualValues(t, locale.Tr("repo.pulls.blocked_by_user"), errorResp.Error) + }) + + // Ensures that comment creation on doer's owned repositories and doer's + // posted issues are blocked. + t.Run("Comment creation", func(t *testing.T) { + expectedMessage := locale.Tr("repo.issues.comment.blocked_by_user") + + t.Run("Blocked by repository owner", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, blockedUser.Name) + + req := NewRequestWithValues(t, "POST", path.Join(issue10URL, "/comments"), map[string]string{ + "_csrf": GetCSRF(t, session, issue10URL), + "content": "Not a kind comment", + }) + resp := session.MakeRequest(t, req, http.StatusBadRequest) + + var errorResp errorJSON + DecodeJSON(t, resp, &errorResp) + + assert.EqualValues(t, expectedMessage, errorResp.Error) + }) + + t.Run("Blocked by issue poster", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo5 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5}) + issue15 := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 15, RepoID: repo5.ID, PosterID: doer.ID}) + + session := loginUser(t, blockedUser.Name) + issueURL := fmt.Sprintf("/%s/%s/issues/%d", url.PathEscape(repo5.OwnerName), url.PathEscape(repo5.Name), issue15.Index) + + req := NewRequestWithValues(t, "POST", path.Join(issueURL, "/comments"), map[string]string{ + "_csrf": GetCSRF(t, session, issueURL), + "content": "Not a kind comment", + }) + resp := session.MakeRequest(t, req, http.StatusBadRequest) + + var errorResp errorJSON + DecodeJSON(t, resp, &errorResp) + + assert.EqualValues(t, expectedMessage, errorResp.Error) + }) + }) + + // Ensures that reactions on doer's owned issues and doer's owned comments are + // blocked. + t.Run("Add a reaction", func(t *testing.T) { + type reactionResponse struct { + Empty bool `json:"empty"` + } + + t.Run("On a issue", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, blockedUser.Name) + + req := NewRequestWithValues(t, "POST", path.Join(issue4URL, "/reactions/react"), map[string]string{ + "_csrf": GetCSRF(t, session, issue4URL), + "content": "eyes", + }) + resp := session.MakeRequest(t, req, http.StatusOK) + + var respBody reactionResponse + DecodeJSON(t, resp, &respBody) + + assert.True(t, respBody.Empty) + }) + + t.Run("On a comment", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issue_model.Comment{ID: 1008, PosterID: doer.ID, IssueID: issue4.ID}) + + session := loginUser(t, blockedUser.Name) + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/comments/%d/reactions/react", repo2.FullName(), comment.ID), map[string]string{ + "_csrf": GetCSRF(t, session, issue4URL), + "content": "eyes", + }) + resp := session.MakeRequest(t, req, http.StatusOK) + + var respBody reactionResponse + DecodeJSON(t, resp, &respBody) + + assert.True(t, respBody.Empty) + }) + }) + + // Ensures that the doer and blocked user cannot follow each other. + t.Run("Follow", func(t *testing.T) { + // Sanity checks to make sure doing these tests are valid. + unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: doer.ID, FollowID: blockedUser.ID}) + unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: blockedUser.ID, FollowID: doer.ID}) + + // Doer cannot follow blocked user. + t.Run("Doer follow blocked user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, doer.Name) + + req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{ + "_csrf": GetCSRF(t, session, "/"+blockedUser.Name), + "action": "follow", + }) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, htmlDoc.Find("#flash-message").Text(), "You cannot follow this user because you have blocked this user or this user has blocked you.") + + // Assert it still doesn't exist. + unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: doer.ID, FollowID: blockedUser.ID}) + }) + + // Blocked user cannot follow doer. + t.Run("Blocked user follow doer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, blockedUser.Name) + + req := NewRequestWithValues(t, "POST", "/"+doer.Name, map[string]string{ + "_csrf": GetCSRF(t, session, "/"+doer.Name), + "action": "follow", + }) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, htmlDoc.Find("#flash-message").Text(), "You cannot follow this user because you have blocked this user or this user has blocked you.") + + unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: blockedUser.ID, FollowID: doer.ID}) + }) + }) + + // Ensures that the doer and blocked user cannot add each each other as collaborators. + t.Run("Add collaborator", func(t *testing.T) { + t.Run("Doer Add BlockedUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, doer.Name) + link := fmt.Sprintf("/%s/settings/collaboration", repo2.FullName()) + + req := NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": GetCSRF(t, session, link), + "collaborator": blockedUser2.Name, + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "error%3DCannot%2Badd%2Bthe%2Bcollaborator%252C%2Bbecause%2Bthe%2Brepository%2Bowner%2Bhas%2Bblocked%2Bthem.", flashCookie.Value) + }) + + t.Run("BlockedUser Add doer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, blockedUser2.Name) + link := fmt.Sprintf("/%s/settings/collaboration", repo7.FullName()) + + req := NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": GetCSRF(t, session, link), + "collaborator": doer.Name, + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "error%3DCannot%2Badd%2Bthe%2Bcollaborator%252C%2Bbecause%2Bthey%2Bhave%2Bblocked%2Bthe%2Brepository%2Bowner.", flashCookie.Value) + }) + }) + + // Ensures that the blocked user cannot transfer a repository to the doer. + t.Run("Repository transfer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, blockedUser2.Name) + link := fmt.Sprintf("%s/settings", repo7.FullName()) + + req := NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": GetCSRF(t, session, link), + "action": "transfer", + "repo_name": repo7.FullName(), + "new_owner_name": doer.Name, + }) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, + htmlDoc.doc.Find(".ui.negative.message").Text(), + translation.NewLocale("en-US").Tr("repo.settings.new_owner_blocked_doer"), + ) + }) +} + +func TestBlockedNotification(t *testing.T) { + defer tests.AddFixtures("tests/integration/fixtures/TestBlockedNotifications")() + defer tests.PrepareTestEnv(t)() + + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + normalUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10}) + issue := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 1000}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + issueURL := fmt.Sprintf("%s/issues/%d", repo.FullName(), issue.Index) + notificationBean := &activities.Notification{UserID: doer.ID, RepoID: repo.ID, IssueID: issue.ID} + + assert.False(t, user_model.IsBlocked(db.DefaultContext, doer.ID, normalUser.ID)) + BlockUser(t, doer, blockedUser) + + mentionDoer := func(t *testing.T, session *TestSession) { + t.Helper() + + req := NewRequestWithValues(t, "POST", issueURL+"/comments", map[string]string{ + "_csrf": GetCSRF(t, session, issueURL), + "content": "I'm annoying. Pinging @" + doer.Name, + }) + session.MakeRequest(t, req, http.StatusOK) + } + + t.Run("Blocks notification of blocked user", func(t *testing.T) { + session := loginUser(t, blockedUser.Name) + + unittest.AssertNotExistsBean(t, notificationBean) + mentionDoer(t, session) + unittest.AssertNotExistsBean(t, notificationBean) + }) + + t.Run("Do not block notifications of normal user", func(t *testing.T) { + session := loginUser(t, normalUser.Name) + + unittest.AssertNotExistsBean(t, notificationBean) + mentionDoer(t, session) + unittest.AssertExistsAndLoadBean(t, notificationBean) + }) +} diff --git a/tests/integration/branches_test.go b/tests/integration/branches_test.go new file mode 100644 index 0000000..e0482b6 --- /dev/null +++ b/tests/integration/branches_test.go @@ -0,0 +1,58 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + gitea_context "code.gitea.io/gitea/services/context" + + "github.com/stretchr/testify/assert" +) + +func TestBranchActions(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user2") + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + branch3 := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{ID: 3, RepoID: repo1.ID}) + branchesLink := repo1.FullName() + "/branches" + + t.Run("View", func(t *testing.T) { + req := NewRequest(t, "GET", branchesLink) + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("Delete branch", func(t *testing.T) { + link := fmt.Sprintf("/%s/branches/delete?name=%s", repo1.FullName(), branch3.Name) + req := NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": GetCSRF(t, session, branchesLink), + }) + session.MakeRequest(t, req, http.StatusOK) + flashCookie := session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.Contains(t, flashCookie.Value, "success%3DBranch%2B%2522branch2%2522%2Bhas%2Bbeen%2Bdeleted.") + + assert.True(t, unittest.AssertExistsAndLoadBean(t, &git_model.Branch{ID: 3, RepoID: repo1.ID}).IsDeleted) + }) + + t.Run("Restore branch", func(t *testing.T) { + link := fmt.Sprintf("/%s/branches/restore?branch_id=%d&name=%s", repo1.FullName(), branch3.ID, branch3.Name) + req := NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": GetCSRF(t, session, branchesLink), + }) + session.MakeRequest(t, req, http.StatusOK) + flashCookie := session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.Contains(t, flashCookie.Value, "success%3DBranch%2B%2522branch2%2522%2Bhas%2Bbeen%2Brestored") + + assert.False(t, unittest.AssertExistsAndLoadBean(t, &git_model.Branch{ID: 3, RepoID: repo1.ID}).IsDeleted) + }) + }) +} diff --git a/tests/integration/change_default_branch_test.go b/tests/integration/change_default_branch_test.go new file mode 100644 index 0000000..703834b --- /dev/null +++ b/tests/integration/change_default_branch_test.go @@ -0,0 +1,40 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/tests" +) + +func TestChangeDefaultBranch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + branchesURL := fmt.Sprintf("/%s/%s/settings/branches", owner.Name, repo.Name) + + csrf := GetCSRF(t, session, branchesURL) + req := NewRequestWithValues(t, "POST", branchesURL, map[string]string{ + "_csrf": csrf, + "action": "default_branch", + "branch": "DefaultBranch", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + csrf = GetCSRF(t, session, branchesURL) + req = NewRequestWithValues(t, "POST", branchesURL, map[string]string{ + "_csrf": csrf, + "action": "default_branch", + "branch": "does_not_exist", + }) + session.MakeRequest(t, req, http.StatusNotFound) +} diff --git a/tests/integration/cmd_admin_test.go b/tests/integration/cmd_admin_test.go new file mode 100644 index 0000000..576b09e --- /dev/null +++ b/tests/integration/cmd_admin_test.go @@ -0,0 +1,147 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/url" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_Cmd_AdminUser(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + for _, testCase := range []struct { + name string + options []string + mustChangePassword bool + }{ + { + name: "default", + options: []string{}, + mustChangePassword: true, + }, + { + name: "--must-change-password=false", + options: []string{"--must-change-password=false"}, + mustChangePassword: false, + }, + { + name: "--must-change-password=true", + options: []string{"--must-change-password=true"}, + mustChangePassword: true, + }, + { + name: "--must-change-password", + options: []string{"--must-change-password"}, + mustChangePassword: true, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + name := "testuser" + + options := []string{"user", "create", "--username", name, "--password", "password", "--email", name + "@example.com"} + options = append(options, testCase.options...) + output, err := runMainApp("admin", options...) + require.NoError(t, err) + assert.Contains(t, output, "has been successfully created") + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: name}) + assert.Equal(t, testCase.mustChangePassword, user.MustChangePassword) + + options = []string{"user", "change-password", "--username", name, "--password", "password"} + options = append(options, testCase.options...) + output, err = runMainApp("admin", options...) + require.NoError(t, err) + assert.Contains(t, output, "has been successfully updated") + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: name}) + assert.Equal(t, testCase.mustChangePassword, user.MustChangePassword) + + _, err = runMainApp("admin", "user", "delete", "--username", name) + require.NoError(t, err) + unittest.AssertNotExistsBean(t, &user_model.User{Name: name}) + }) + } + }) +} + +func Test_Cmd_AdminFirstUser(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + for _, testCase := range []struct { + name string + options []string + mustChangePassword bool + isAdmin bool + }{ + { + name: "default", + options: []string{}, + mustChangePassword: false, + isAdmin: false, + }, + { + name: "--must-change-password=false", + options: []string{"--must-change-password=false"}, + mustChangePassword: false, + isAdmin: false, + }, + { + name: "--must-change-password=true", + options: []string{"--must-change-password=true"}, + mustChangePassword: true, + isAdmin: false, + }, + { + name: "--must-change-password", + options: []string{"--must-change-password"}, + mustChangePassword: true, + isAdmin: false, + }, + { + name: "--admin default", + options: []string{"--admin"}, + mustChangePassword: false, + isAdmin: true, + }, + { + name: "--admin --must-change-password=false", + options: []string{"--admin", "--must-change-password=false"}, + mustChangePassword: false, + isAdmin: true, + }, + { + name: "--admin --must-change-password=true", + options: []string{"--admin", "--must-change-password=true"}, + mustChangePassword: true, + isAdmin: true, + }, + { + name: "--admin --must-change-password", + options: []string{"--admin", "--must-change-password"}, + mustChangePassword: true, + isAdmin: true, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + db.GetEngine(db.DefaultContext).Exec("DELETE FROM `user`") + db.GetEngine(db.DefaultContext).Exec("DELETE FROM `email_address`") + assert.Equal(t, int64(0), user_model.CountUsers(db.DefaultContext, nil)) + name := "testuser" + + options := []string{"user", "create", "--username", name, "--password", "password", "--email", name + "@example.com"} + options = append(options, testCase.options...) + output, err := runMainApp("admin", options...) + require.NoError(t, err) + assert.Contains(t, output, "has been successfully created") + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: name}) + assert.Equal(t, testCase.mustChangePassword, user.MustChangePassword) + assert.Equal(t, testCase.isAdmin, user.IsAdmin) + }) + } + }) +} diff --git a/tests/integration/cmd_forgejo_actions_test.go b/tests/integration/cmd_forgejo_actions_test.go new file mode 100644 index 0000000..067cdef --- /dev/null +++ b/tests/integration/cmd_forgejo_actions_test.go @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MIT + +package integration + +import ( + gocontext "context" + "io" + "net/url" + "os" + "os/exec" + "strings" + "testing" + + actions_model "code.gitea.io/gitea/models/actions" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_CmdForgejo_Actions(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + token, err := runMainApp("forgejo-cli", "actions", "generate-runner-token") + require.NoError(t, err) + assert.Len(t, token, 40) + + secret, err := runMainApp("forgejo-cli", "actions", "generate-secret") + require.NoError(t, err) + assert.Len(t, secret, 40) + + _, err = runMainApp("forgejo-cli", "actions", "register") + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + assert.Contains(t, string(exitErr.Stderr), "at least one of the --secret") + + for _, testCase := range []struct { + testName string + scope string + secret string + errorMessage string + }{ + { + testName: "bad user", + scope: "baduser", + secret: "0123456789012345678901234567890123456789", + errorMessage: "user does not exist", + }, + { + testName: "bad repo", + scope: "org25/badrepo", + secret: "0123456789012345678901234567890123456789", + errorMessage: "repository does not exist", + }, + { + testName: "secret length != 40", + scope: "org25", + secret: "0123456789", + errorMessage: "40 characters long", + }, + { + testName: "secret is not a hexadecimal string", + scope: "org25", + secret: "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", + errorMessage: "must be an hexadecimal string", + }, + } { + t.Run(testCase.testName, func(t *testing.T) { + output, err := runMainApp("forgejo-cli", "actions", "register", "--secret", testCase.secret, "--scope", testCase.scope) + assert.EqualValues(t, "", output) + + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + assert.Contains(t, string(exitErr.Stderr), testCase.errorMessage) + }) + } + + secret = "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD" + expecteduuid := "44444444-4444-4444-4444-444444444444" + + for _, testCase := range []struct { + testName string + secretOption func() string + stdin io.Reader + }{ + { + testName: "secret from argument", + secretOption: func() string { + return "--secret=" + secret + }, + }, + { + testName: "secret from stdin", + secretOption: func() string { + return "--secret-stdin" + }, + stdin: strings.NewReader(secret), + }, + { + testName: "secret from file", + secretOption: func() string { + secretFile := t.TempDir() + "/secret" + require.NoError(t, os.WriteFile(secretFile, []byte(secret), 0o644)) + return "--secret-file=" + secretFile + }, + }, + } { + t.Run(testCase.testName, func(t *testing.T) { + uuid, err := runMainAppWithStdin(testCase.stdin, "forgejo-cli", "actions", "register", testCase.secretOption(), "--scope=org26") + require.NoError(t, err) + assert.EqualValues(t, expecteduuid, uuid) + }) + } + + secret = "0123456789012345678901234567890123456789" + expecteduuid = "30313233-3435-3637-3839-303132333435" + + for _, testCase := range []struct { + testName string + scope string + secret string + name string + labels string + version string + uuid string + }{ + { + testName: "org", + scope: "org25", + secret: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + uuid: "41414141-4141-4141-4141-414141414141", + }, + { + testName: "user and repo", + scope: "user2/repo2", + secret: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + uuid: "42424242-4242-4242-4242-424242424242", + }, + { + testName: "labels", + scope: "org25", + name: "runnerName", + labels: "label1,label2,label3", + version: "v1.2.3", + secret: "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", + uuid: "43434343-4343-4343-4343-434343434343", + }, + { + testName: "insert a runner", + scope: "user10/repo6", + name: "runnerName", + labels: "label1,label2,label3", + version: "v1.2.3", + secret: secret, + uuid: expecteduuid, + }, + { + testName: "update an existing runner", + scope: "user5/repo4", + name: "runnerNameChanged", + labels: "label1,label2,label3,more,label", + version: "v1.2.3-suffix", + secret: secret, + uuid: expecteduuid, + }, + } { + t.Run(testCase.testName, func(t *testing.T) { + cmd := []string{ + "actions", "register", + "--secret", testCase.secret, "--scope", testCase.scope, + } + if testCase.name != "" { + cmd = append(cmd, "--name", testCase.name) + } + if testCase.labels != "" { + cmd = append(cmd, "--labels", testCase.labels) + } + if testCase.version != "" { + cmd = append(cmd, "--version", testCase.version) + } + // + // Run twice to verify it is idempotent + // + for i := 0; i < 2; i++ { + uuid, err := runMainApp("forgejo-cli", cmd...) + require.NoError(t, err) + if assert.EqualValues(t, testCase.uuid, uuid) { + ownerName, repoName, found := strings.Cut(testCase.scope, "/") + action, err := actions_model.GetRunnerByUUID(gocontext.Background(), uuid) + require.NoError(t, err) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: action.OwnerID}) + assert.Equal(t, ownerName, user.Name, action.OwnerID) + + if found { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: action.RepoID}) + assert.Equal(t, repoName, repo.Name, action.RepoID) + } + if testCase.name != "" { + assert.EqualValues(t, testCase.name, action.Name) + } + if testCase.labels != "" { + labels := strings.Split(testCase.labels, ",") + assert.EqualValues(t, labels, action.AgentLabels) + } + if testCase.version != "" { + assert.EqualValues(t, testCase.version, action.Version) + } + } + } + }) + } + }) +} diff --git a/tests/integration/cmd_forgejo_f3_test.go b/tests/integration/cmd_forgejo_f3_test.go new file mode 100644 index 0000000..9156405 --- /dev/null +++ b/tests/integration/cmd_forgejo_f3_test.go @@ -0,0 +1,137 @@ +// Copyright Earl Warren <contact@earl-warren.org> +// Copyright Loïc Dachary <loic@dachary.org> +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "testing" + + "code.gitea.io/gitea/cmd/forgejo" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/f3/driver/options" + "code.gitea.io/gitea/tests" + + _ "code.gitea.io/gitea/services/f3/driver" + _ "code.gitea.io/gitea/services/f3/driver/tests" + + f3_filesystem_options "code.forgejo.org/f3/gof3/v3/forges/filesystem/options" + f3_logger "code.forgejo.org/f3/gof3/v3/logger" + f3_options "code.forgejo.org/f3/gof3/v3/options" + f3_generic "code.forgejo.org/f3/gof3/v3/tree/generic" + f3_tests "code.forgejo.org/f3/gof3/v3/tree/tests/f3" + f3_tests_forge "code.forgejo.org/f3/gof3/v3/tree/tests/f3/forge" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" +) + +func runApp(ctx context.Context, args ...string) (string, error) { + l := f3_logger.NewCaptureLogger() + ctx = f3_logger.ContextSetLogger(ctx, l) + ctx = forgejo.ContextSetNoInit(ctx, true) + + app := cli.NewApp() + + app.Writer = l.GetBuffer() + app.ErrWriter = l.GetBuffer() + + defer func() { + if r := recover(); r != nil { + fmt.Println(l.String()) + panic(r) + } + }() + + app.Commands = []*cli.Command{ + forgejo.SubcmdF3Mirror(ctx), + } + err := app.Run(args) + + fmt.Println(l.String()) + + return l.String(), err +} + +func TestF3_CmdMirror_LocalForgejo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.F3.Enabled, true)() + + ctx := context.Background() + + mirrorOptions := f3_tests_forge.GetFactory(options.Name)().NewOptions(t) + mirrorTree := f3_generic.GetFactory("f3")(ctx, mirrorOptions) + + fixtureOptions := f3_tests_forge.GetFactory(f3_filesystem_options.Name)().NewOptions(t) + fixtureTree := f3_generic.GetFactory("f3")(ctx, fixtureOptions) + + log := fixtureTree.GetLogger() + creator := f3_tests.NewCreator(t, "CmdMirrorLocalForgejo", log) + + log.Trace("======= build fixture") + + var fromPath string + { + fixtureUserID := "userID01" + fixtureProjectID := "projectID01" + + userFormat := creator.GenerateUser() + userFormat.SetID(fixtureUserID) + users := fixtureTree.MustFind(f3_generic.NewPathFromString("/forge/users")) + user := users.CreateChild(ctx) + user.FromFormat(userFormat) + user.Upsert(ctx) + require.EqualValues(t, user.GetID(), users.GetIDFromName(ctx, userFormat.UserName)) + + projectFormat := creator.GenerateProject() + projectFormat.SetID(fixtureProjectID) + projects := user.MustFind(f3_generic.NewPathFromString("projects")) + project := projects.CreateChild(ctx) + project.FromFormat(projectFormat) + project.Upsert(ctx) + require.EqualValues(t, project.GetID(), projects.GetIDFromName(ctx, projectFormat.Name)) + + fromPath = fmt.Sprintf("/forge/users/%s/projects/%s", userFormat.UserName, projectFormat.Name) + } + + log.Trace("======= create mirror") + + var toPath string + var projects f3_generic.NodeInterface + { + userFormat := creator.GenerateUser() + users := mirrorTree.MustFind(f3_generic.NewPathFromString("/forge/users")) + user := users.CreateChild(ctx) + user.FromFormat(userFormat) + user.Upsert(ctx) + require.EqualValues(t, user.GetID(), users.GetIDFromName(ctx, userFormat.UserName)) + + projectFormat := creator.GenerateProject() + projects = user.MustFind(f3_generic.NewPathFromString("projects")) + project := projects.CreateChild(ctx) + project.FromFormat(projectFormat) + project.Upsert(ctx) + require.EqualValues(t, project.GetID(), projects.GetIDFromName(ctx, projectFormat.Name)) + + toPath = fmt.Sprintf("/forge/users/%s/projects/%s", userFormat.UserName, projectFormat.Name) + } + + log.Trace("======= mirror %s => %s", fromPath, toPath) + output, err := runApp(ctx, + "f3", "mirror", + "--from-type", f3_filesystem_options.Name, + "--from-path", fromPath, + "--from-filesystem-directory", fixtureOptions.(f3_options.URLInterface).GetURL(), + + "--to-type", options.Name, + "--to-path", toPath, + ) + require.NoError(t, err) + log.Trace("======= assert") + require.Contains(t, output, fmt.Sprintf("mirror %s", fromPath)) + projects.List(ctx) + require.NotEmpty(t, projects.GetChildren()) + log.Trace("======= project %s", projects.GetChildren()[0]) +} diff --git a/tests/integration/cmd_keys_test.go b/tests/integration/cmd_keys_test.go new file mode 100644 index 0000000..e93a8b5 --- /dev/null +++ b/tests/integration/cmd_keys_test.go @@ -0,0 +1,54 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "errors" + "net/url" + "os/exec" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_CmdKeys(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + tests := []struct { + name string + args []string + wantErr bool + expectedOutput string + }{ + {"test_empty_1", []string{"--username=git", "--type=test", "--content=test"}, true, ""}, + {"test_empty_2", []string{"-e", "git", "-u", "git", "-t", "test", "-k", "test"}, true, ""}, + { + "with_key", + []string{"-e", "git", "-u", "git", "-t", "ssh-rsa", "-k", "AAAAB3NzaC1yc2EAAAADAQABAAABgQDWVj0fQ5N8wNc0LVNA41wDLYJ89ZIbejrPfg/avyj3u/ZohAKsQclxG4Ju0VirduBFF9EOiuxoiFBRr3xRpqzpsZtnMPkWVWb+akZwBFAx8p+jKdy4QXR/SZqbVobrGwip2UjSrri1CtBxpJikojRIZfCnDaMOyd9Jp6KkujvniFzUWdLmCPxUE9zhTaPu0JsEP7MW0m6yx7ZUhHyfss+NtqmFTaDO+QlMR7L2QkDliN2Jl3Xa3PhuWnKJfWhdAq1Cw4oraKUOmIgXLkuiuxVQ6mD3AiFupkmfqdHq6h+uHHmyQqv3gU+/sD8GbGAhf6ftqhTsXjnv1Aj4R8NoDf9BS6KRkzkeun5UisSzgtfQzjOMEiJtmrep2ZQrMGahrXa+q4VKr0aKJfm+KlLfwm/JztfsBcqQWNcTURiCFqz+fgZw0Ey/de0eyMzldYTdXXNRYCKjs9bvBK+6SSXRM7AhftfQ0ZuoW5+gtinPrnmoOaSCEJbAiEiTO/BzOHgowiM="}, + false, + "# gitea public key\ncommand=\"" + setting.AppPath + " --config=" + util.ShellEscape(setting.CustomConf) + " serv key-1\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDWVj0fQ5N8wNc0LVNA41wDLYJ89ZIbejrPfg/avyj3u/ZohAKsQclxG4Ju0VirduBFF9EOiuxoiFBRr3xRpqzpsZtnMPkWVWb+akZwBFAx8p+jKdy4QXR/SZqbVobrGwip2UjSrri1CtBxpJikojRIZfCnDaMOyd9Jp6KkujvniFzUWdLmCPxUE9zhTaPu0JsEP7MW0m6yx7ZUhHyfss+NtqmFTaDO+QlMR7L2QkDliN2Jl3Xa3PhuWnKJfWhdAq1Cw4oraKUOmIgXLkuiuxVQ6mD3AiFupkmfqdHq6h+uHHmyQqv3gU+/sD8GbGAhf6ftqhTsXjnv1Aj4R8NoDf9BS6KRkzkeun5UisSzgtfQzjOMEiJtmrep2ZQrMGahrXa+q4VKr0aKJfm+KlLfwm/JztfsBcqQWNcTURiCFqz+fgZw0Ey/de0eyMzldYTdXXNRYCKjs9bvBK+6SSXRM7AhftfQ0ZuoW5+gtinPrnmoOaSCEJbAiEiTO/BzOHgowiM= user2@localhost\n", + }, + {"invalid", []string{"--not-a-flag=git"}, true, "Incorrect Usage: flag provided but not defined: -not-a-flag\n\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := runMainApp("keys", tt.args...) + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + t.Log(string(exitErr.Stderr)) + } + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.expectedOutput, out) + }) + } + }) +} diff --git a/tests/integration/codeowner_test.go b/tests/integration/codeowner_test.go new file mode 100644 index 0000000..6ef3546 --- /dev/null +++ b/tests/integration/codeowner_test.go @@ -0,0 +1,201 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "os" + "path" + "strings" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/require" +) + +func TestCodeOwner(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // Create the repo. + repo, _, f := tests.CreateDeclarativeRepo(t, user2, "", + []unit_model.Type{unit_model.TypePullRequests}, nil, + []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "CODEOWNERS", + ContentReader: strings.NewReader("README.md @user5\ntest-file @user4"), + }, + }, + ) + defer f() + + dstPath := t.TempDir() + r := fmt.Sprintf("%suser2/%s.git", u.String(), repo.Name) + cloneURL, _ := url.Parse(r) + cloneURL.User = url.UserPassword("user2", userPassword) + require.NoError(t, git.CloneWithArgs(context.Background(), nil, cloneURL.String(), dstPath, git.CloneRepoOptions{})) + + t.Run("Normal", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + err := os.WriteFile(path.Join(dstPath, "README.md"), []byte("## test content"), 0o666) + require.NoError(t, err) + + err = git.AddChanges(dstPath, true) + require.NoError(t, err) + + err = git.CommitChanges(dstPath, git.CommitChangesOptions{ + Committer: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Author: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Message: "Add README.", + }) + require.NoError(t, err) + + err = git.NewCommand(git.DefaultContext, "push", "origin", "HEAD:refs/for/main", "-o", "topic=codeowner-normal").Run(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadBranch: "user2/codeowner-normal"}) + unittest.AssertExistsIf(t, true, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 5}) + }) + + t.Run("Forked repository", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, "user1") + testRepoFork(t, session, user2.Name, repo.Name, "user1", "repo1") + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user1", Name: "repo1"}) + + r := fmt.Sprintf("%suser1/repo1.git", u.String()) + remoteURL, _ := url.Parse(r) + remoteURL.User = url.UserPassword("user2", userPassword) + doGitAddRemote(dstPath, "forked", remoteURL)(t) + + err := git.NewCommand(git.DefaultContext, "push", "forked", "HEAD:refs/for/main", "-o", "topic=codeowner-forked").Run(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadBranch: "user2/codeowner-forked"}) + unittest.AssertExistsIf(t, false, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 5}) + }) + + t.Run("Out of date", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Push the changes made from the previous subtest. + require.NoError(t, git.NewCommand(git.DefaultContext, "push", "origin").Run(&git.RunOpts{Dir: dstPath})) + + // Reset the tree to the previous commit. + require.NoError(t, git.NewCommand(git.DefaultContext, "reset", "--hard", "HEAD~1").Run(&git.RunOpts{Dir: dstPath})) + + err := os.WriteFile(path.Join(dstPath, "test-file"), []byte("## test content"), 0o666) + require.NoError(t, err) + + err = git.AddChanges(dstPath, true) + require.NoError(t, err) + + err = git.CommitChanges(dstPath, git.CommitChangesOptions{ + Committer: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Author: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Message: "Add test-file.", + }) + require.NoError(t, err) + + err = git.NewCommand(git.DefaultContext, "push", "origin", "HEAD:refs/for/main", "-o", "topic=codeowner-out-of-date").Run(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadBranch: "user2/codeowner-out-of-date"}) + unittest.AssertExistsIf(t, true, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 4}) + unittest.AssertExistsIf(t, false, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 5}) + }) + t.Run("From a forked repository", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, "user1") + + r := fmt.Sprintf("%suser1/repo1.git", u.String()) + remoteURL, _ := url.Parse(r) + remoteURL.User = url.UserPassword("user1", userPassword) + doGitAddRemote(dstPath, "forked-2", remoteURL)(t) + + err := git.NewCommand(git.DefaultContext, "push", "forked-2", "HEAD:branch").Run(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + req := NewRequestWithValues(t, "POST", repo.FullName()+"/compare/main...user1/repo1:branch", map[string]string{ + "_csrf": GetCSRF(t, session, repo.FullName()+"/compare/main...user1/repo1:branch"), + "title": "pull request", + }) + session.MakeRequest(t, req, http.StatusOK) + + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadBranch: "branch"}) + unittest.AssertExistsIf(t, true, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 4}) + }) + + t.Run("Codeowner user with no permission", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Make repository private, only user2 (owner of repository) has now access to this repository. + repo.IsPrivate = true + _, err := db.GetEngine(db.DefaultContext).Cols("is_private").Update(repo) + require.NoError(t, err) + + err = os.WriteFile(path.Join(dstPath, "README.md"), []byte("## very senstive info"), 0o666) + require.NoError(t, err) + + err = git.AddChanges(dstPath, true) + require.NoError(t, err) + + err = git.CommitChanges(dstPath, git.CommitChangesOptions{ + Committer: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Author: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Message: "Add secrets to the README.", + }) + require.NoError(t, err) + + err = git.NewCommand(git.DefaultContext, "push", "origin", "HEAD:refs/for/main", "-o", "topic=codeowner-private").Run(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + // In CODEOWNERS file the codeowner for README.md is user5, but does not have access to this private repository. + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadBranch: "user2/codeowner-private"}) + unittest.AssertExistsIf(t, false, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 5}) + }) + }) +} diff --git a/tests/integration/compare_test.go b/tests/integration/compare_test.go new file mode 100644 index 0000000..c65335c --- /dev/null +++ b/tests/integration/compare_test.go @@ -0,0 +1,293 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/test" + repo_service "code.gitea.io/gitea/services/repository" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompareTag(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user2/repo1/compare/v1.1...master") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + selection := htmlDoc.doc.Find(".choose.branch .filter.dropdown") + // A dropdown for both base and head. + assert.Lenf(t, selection.Nodes, 2, "The template has changed") + + req = NewRequest(t, "GET", "/user2/repo1/compare/invalid") + resp = session.MakeRequest(t, req, http.StatusNotFound) + assert.False(t, strings.Contains(resp.Body.String(), ">500<"), "expect 404 page not 500") +} + +// Compare with inferred default branch (master) +func TestCompareDefault(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user2/repo1/compare/v1.1") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + selection := htmlDoc.doc.Find(".choose.branch .filter.dropdown") + assert.Lenf(t, selection.Nodes, 2, "The template has changed") +} + +// Ensure the comparison matches what we expect +func inspectCompare(t *testing.T, htmlDoc *HTMLDoc, diffCount int, diffChanges []string) { + selection := htmlDoc.doc.Find("#diff-file-boxes").Children() + + assert.Lenf(t, selection.Nodes, diffCount, "Expected %v diffed files, found: %v", diffCount, len(selection.Nodes)) + + for _, diffChange := range diffChanges { + selection = htmlDoc.doc.Find(fmt.Sprintf("[data-new-filename=\"%s\"]", diffChange)) + assert.Lenf(t, selection.Nodes, 1, "Expected 1 match for [data-new-filename=\"%s\"], found: %v", diffChange, len(selection.Nodes)) + } +} + +// Git commit graph for repo20 +// * 8babce9 (origin/remove-files-b) Add a dummy file +// * b67e43a Delete test.csv and link_hi +// | * cfe3b3c (origin/remove-files-a) Delete test.csv and link_hi +// |/ +// * c8e31bc (origin/add-csv) Add test csv file +// * 808038d (HEAD -> master, origin/master, origin/HEAD) Added test links + +func TestCompareBranches(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + // Indirect compare remove-files-b (head) with add-csv (base) branch + // + // 'link_hi' and 'test.csv' are deleted, 'test.txt' is added + req := NewRequest(t, "GET", "/user2/repo20/compare/add-csv...remove-files-b") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + diffCount := 3 + diffChanges := []string{"link_hi", "test.csv", "test.txt"} + + inspectCompare(t, htmlDoc, diffCount, diffChanges) + + // Indirect compare remove-files-b (head) with remove-files-a (base) branch + // + // 'link_hi' and 'test.csv' are deleted, 'test.txt' is added + + req = NewRequest(t, "GET", "/user2/repo20/compare/remove-files-a...remove-files-b") + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + + diffCount = 3 + diffChanges = []string{"link_hi", "test.csv", "test.txt"} + + inspectCompare(t, htmlDoc, diffCount, diffChanges) + + // Indirect compare remove-files-a (head) with remove-files-b (base) branch + // + // 'link_hi' and 'test.csv' are deleted + + req = NewRequest(t, "GET", "/user2/repo20/compare/remove-files-b...remove-files-a") + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + + diffCount = 2 + diffChanges = []string{"link_hi", "test.csv"} + + inspectCompare(t, htmlDoc, diffCount, diffChanges) + + // Direct compare remove-files-b (head) with remove-files-a (base) branch + // + // 'test.txt' is deleted + + req = NewRequest(t, "GET", "/user2/repo20/compare/remove-files-b..remove-files-a") + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + + diffCount = 1 + diffChanges = []string{"test.txt"} + + inspectCompare(t, htmlDoc, diffCount, diffChanges) +} + +func TestCompareWithPRsDisabled(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testCreateBranch(t, session, "user1", "repo1", "branch/master", "recent-push", http.StatusSeeOther) + testEditFile(t, session, "user1", "repo1", "recent-push", "README.md", "Hello recently!\n") + + repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user1", "repo1") + require.NoError(t, err) + + defer func() { + // Re-enable PRs on the repo + err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, + []repo_model.RepoUnit{{ + RepoID: repo.ID, + Type: unit_model.TypePullRequests, + }}, + nil) + require.NoError(t, err) + }() + + // Disable PRs on the repo + err = repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, nil, + []unit_model.Type{unit_model.TypePullRequests}) + require.NoError(t, err) + + t.Run("branch view doesn't offer creating PRs", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user1/repo1/branches") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, "a[href='/user1/repo1/compare/master...recent-push']", false) + }) + + t.Run("compare doesn't offer local branches", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/compare/master...user1/repo1:recent-push") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + branches := htmlDoc.Find(".choose.branch .menu .reference-list-menu.base-branch-list .item, .choose.branch .menu .reference-list-menu.base-tag-list .item") + + expectedPrefix := "user2:" + for i := 0; i < len(branches.Nodes); i++ { + assert.True(t, strings.HasPrefix(branches.Eq(i).Text(), expectedPrefix)) + } + }) + + t.Run("comparing against a disabled-PR repo is 404", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user1/repo1/compare/master...recent-push") + session.MakeRequest(t, req, http.StatusNotFound) + }) + }) +} + +func TestCompareCrossRepo(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1-copy") + testCreateBranch(t, session, "user1", "repo1-copy", "branch/master", "recent-push", http.StatusSeeOther) + testEditFile(t, session, "user1", "repo1-copy", "recent-push", "README.md", "Hello recently!\n") + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user1", Name: "repo1-copy"}) + + gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo) + require.NoError(t, err) + defer gitRepo.Close() + + lastCommit, err := gitRepo.GetBranchCommitID("recent-push") + require.NoError(t, err) + assert.NotEmpty(t, lastCommit) + + t.Run("view file button links to correct file in fork", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/compare/master...user1/repo1-copy:recent-push") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, "a[href='/user1/repo1-copy/src/commit/"+lastCommit+"/README.md']", true) + htmlDoc.AssertElement(t, "a[href='/user1/repo1/src/commit/"+lastCommit+"/README.md']", false) + }) + }) +} + +func TestCompareCodeExpand(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + // Create a new repository, with a file that has many lines + repo, _, f := tests.CreateDeclarativeRepoWithOptions(t, owner, tests.DeclarativeRepoOptions{ + Files: optional.Some([]*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "docs.md", + ContentReader: strings.NewReader("01\n02\n03\n04\n05\n06\n07\n08\n09\n0a\n0b\n0c\n0d\n0e\n0f\n10\n11\n12\n12\n13\n14\n15\n16\n17\n18\n19\n1a\n1b\n1c\n1d\n1e\n1f\n20\n"), + }, + }), + }) + defer f() + + // Fork the repository + forker := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, forker.Name) + testRepoFork(t, session, owner.Name, repo.Name, forker.Name, repo.Name+"-copy") + testCreateBranch(t, session, forker.Name, repo.Name+"-copy", "branch/main", "code-expand", http.StatusSeeOther) + + // Edit the file, insert a line somewhere in the middle + testEditFile(t, session, forker.Name, repo.Name+"-copy", "code-expand", "docs.md", + "01\n02\n03\n04\n05\n06\n07\n08\n09\n0a\n0b\n0c\n0d\n0e\n0f\n10\n11\nHELLO WORLD!\n12\n12\n13\n14\n15\n16\n17\n18\n19\n1a\n1b\n1c\n1d\n1e\n1f\n20\n", + ) + + t.Run("code expander targets the fork", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestf(t, "GET", "%s/%s/compare/main...%s/%s:code-expand", + owner.Name, repo.Name, forker.Name, repo.Name+"-copy") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + els := htmlDoc.Find(`button.code-expander-button[hx-get]`) + + // all the links in the comparison should be to the forked repo&branch + assert.NotZero(t, els.Length()) + expectedPrefix := fmt.Sprintf("/%s/%s/blob_excerpt/", forker.Name, repo.Name+"-copy") + for i := 0; i < els.Length(); i++ { + link := els.Eq(i).AttrOr("hx-get", "") + assert.True(t, strings.HasPrefix(link, expectedPrefix)) + } + }) + + t.Run("code expander targets the repo in a PR", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create a pullrequest + resp := testPullCreate(t, session, forker.Name, repo.Name+"-copy", false, "main", "code-expand", "This is a pull title") + + // Grab the URL for the PR + url := test.RedirectURL(resp) + "/files" + + // Visit the PR's diff + req := NewRequest(t, "GET", url) + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + els := htmlDoc.Find(`button.code-expander-button[hx-get]`) + + // all the links in the comparison should be to the original repo&branch + assert.NotZero(t, els.Length()) + expectedPrefix := fmt.Sprintf("/%s/%s/blob_excerpt/", owner.Name, repo.Name) + for i := 0; i < els.Length(); i++ { + link := els.Eq(i).AttrOr("hx-get", "") + assert.True(t, strings.HasPrefix(link, expectedPrefix)) + } + }) + }) +} diff --git a/tests/integration/cors_test.go b/tests/integration/cors_test.go new file mode 100644 index 0000000..25dfbab --- /dev/null +++ b/tests/integration/cors_test.go @@ -0,0 +1,94 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestCORS(t *testing.T) { + defer tests.PrepareTestEnv(t)() + t.Run("CORS enabled", func(t *testing.T) { + defer test.MockVariableValue(&setting.CORSConfig.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + t.Run("API with CORS", func(t *testing.T) { + // GET api with no CORS header + req := NewRequest(t, "GET", "/api/v1/version") + resp := MakeRequest(t, req, http.StatusOK) + assert.Empty(t, resp.Header().Get("Access-Control-Allow-Origin")) + assert.Contains(t, resp.Header().Values("Vary"), "Origin") + + // OPTIONS api for CORS + req = NewRequest(t, "OPTIONS", "/api/v1/version"). + SetHeader("Origin", "https://example.com"). + SetHeader("Access-Control-Request-Method", "GET") + resp = MakeRequest(t, req, http.StatusOK) + assert.NotEmpty(t, resp.Header().Get("Access-Control-Allow-Origin")) + assert.Contains(t, resp.Header().Values("Vary"), "Origin") + }) + + t.Run("Web with CORS", func(t *testing.T) { + // GET userinfo with no CORS header + req := NewRequest(t, "GET", "/login/oauth/userinfo") + resp := MakeRequest(t, req, http.StatusUnauthorized) + assert.Empty(t, resp.Header().Get("Access-Control-Allow-Origin")) + assert.Contains(t, resp.Header().Values("Vary"), "Origin") + + // OPTIONS userinfo for CORS + req = NewRequest(t, "OPTIONS", "/login/oauth/userinfo"). + SetHeader("Origin", "https://example.com"). + SetHeader("Access-Control-Request-Method", "GET") + resp = MakeRequest(t, req, http.StatusOK) + assert.NotEmpty(t, resp.Header().Get("Access-Control-Allow-Origin")) + assert.Contains(t, resp.Header().Values("Vary"), "Origin") + + // OPTIONS userinfo for non-CORS + req = NewRequest(t, "OPTIONS", "/login/oauth/userinfo") + resp = MakeRequest(t, req, http.StatusMethodNotAllowed) + assert.NotContains(t, resp.Header().Values("Vary"), "Origin") + }) + }) + + t.Run("CORS disabled", func(t *testing.T) { + defer test.MockVariableValue(&setting.CORSConfig.Enabled, false)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + t.Run("API without CORS", func(t *testing.T) { + req := NewRequest(t, "GET", "/api/v1/version") + resp := MakeRequest(t, req, http.StatusOK) + assert.Empty(t, resp.Header().Get("Access-Control-Allow-Origin")) + assert.Empty(t, resp.Header().Values("Vary")) + + req = NewRequest(t, "OPTIONS", "/api/v1/version"). + SetHeader("Origin", "https://example.com"). + SetHeader("Access-Control-Request-Method", "GET") + resp = MakeRequest(t, req, http.StatusMethodNotAllowed) + assert.Empty(t, resp.Header().Get("Access-Control-Allow-Origin")) + assert.Empty(t, resp.Header().Values("Vary")) + }) + + t.Run("Web without CORS", func(t *testing.T) { + req := NewRequest(t, "GET", "/login/oauth/userinfo") + resp := MakeRequest(t, req, http.StatusUnauthorized) + assert.Empty(t, resp.Header().Get("Access-Control-Allow-Origin")) + assert.NotContains(t, resp.Header().Values("Vary"), "Origin") + + req = NewRequest(t, "OPTIONS", "/login/oauth/userinfo"). + SetHeader("Origin", "https://example.com"). + SetHeader("Access-Control-Request-Method", "GET") + resp = MakeRequest(t, req, http.StatusMethodNotAllowed) + assert.Empty(t, resp.Header().Get("Access-Control-Allow-Origin")) + assert.NotContains(t, resp.Header().Values("Vary"), "Origin") + }) + }) +} diff --git a/tests/integration/create_no_session_test.go b/tests/integration/create_no_session_test.go new file mode 100644 index 0000000..ca2a775 --- /dev/null +++ b/tests/integration/create_no_session_test.go @@ -0,0 +1,112 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/tests" + + "code.forgejo.org/go-chi/session" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func getSessionID(t *testing.T, resp *httptest.ResponseRecorder) string { + cookies := resp.Result().Cookies() + found := false + sessionID := "" + for _, cookie := range cookies { + if cookie.Name == setting.SessionConfig.CookieName { + sessionID = cookie.Value + found = true + } + } + assert.True(t, found) + assert.NotEmpty(t, sessionID) + return sessionID +} + +func sessionFile(tmpDir, sessionID string) string { + return filepath.Join(tmpDir, sessionID[0:1], sessionID[1:2], sessionID) +} + +func sessionFileExist(t *testing.T, tmpDir, sessionID string) bool { + sessionFile := sessionFile(tmpDir, sessionID) + _, err := os.Lstat(sessionFile) + if err != nil { + if os.IsNotExist(err) { + return false + } + require.NoError(t, err) + } + return true +} + +func TestSessionFileCreation(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + oldSessionConfig := setting.SessionConfig.ProviderConfig + defer func() { + setting.SessionConfig.ProviderConfig = oldSessionConfig + testWebRoutes = routers.NormalRoutes() + }() + + var config session.Options + + err := json.Unmarshal([]byte(oldSessionConfig), &config) + require.NoError(t, err) + + config.Provider = "file" + + // Now create a temporaryDirectory + tmpDir := t.TempDir() + config.ProviderConfig = tmpDir + + newConfigBytes, err := json.Marshal(config) + require.NoError(t, err) + + setting.SessionConfig.ProviderConfig = string(newConfigBytes) + + testWebRoutes = routers.NormalRoutes() + + t.Run("NoSessionOnViewIssue", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues/1") + resp := MakeRequest(t, req, http.StatusOK) + sessionID := getSessionID(t, resp) + + // We're not logged in so there should be no session + assert.False(t, sessionFileExist(t, tmpDir, sessionID)) + }) + t.Run("CreateSessionOnLogin", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user/login") + resp := MakeRequest(t, req, http.StatusOK) + sessionID := getSessionID(t, resp) + + // We're not logged in so there should be no session + assert.False(t, sessionFileExist(t, tmpDir, sessionID)) + + doc := NewHTMLParser(t, resp.Body) + req = NewRequestWithValues(t, "POST", "/user/login", map[string]string{ + "_csrf": doc.GetCSRF(), + "user_name": "user2", + "password": userPassword, + }) + resp = MakeRequest(t, req, http.StatusSeeOther) + sessionID = getSessionID(t, resp) + + assert.FileExists(t, sessionFile(tmpDir, sessionID)) + }) +} diff --git a/tests/integration/csrf_test.go b/tests/integration/csrf_test.go new file mode 100644 index 0000000..fcb9661 --- /dev/null +++ b/tests/integration/csrf_test.go @@ -0,0 +1,34 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestCsrfProtection(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // test web form csrf via form + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ + "_csrf": "fake_csrf", + }) + resp := session.MakeRequest(t, req, http.StatusBadRequest) + assert.Contains(t, resp.Body.String(), "Invalid CSRF token") + + // test web form csrf via header. TODO: should use an UI api to test + req = NewRequest(t, "POST", "/user/settings") + req.Header.Add("X-Csrf-Token", "fake_csrf") + resp = session.MakeRequest(t, req, http.StatusBadRequest) + assert.Contains(t, resp.Body.String(), "Invalid CSRF token") +} diff --git a/tests/integration/db_collation_test.go b/tests/integration/db_collation_test.go new file mode 100644 index 0000000..0e5bf00 --- /dev/null +++ b/tests/integration/db_collation_test.go @@ -0,0 +1,149 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "xorm.io/xorm" +) + +type TestCollationTbl struct { + ID int64 + Txt string `xorm:"VARCHAR(10) UNIQUE"` +} + +func TestDatabaseCollationSelfCheckUI(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + assertSelfCheckExists := func(exists bool) { + expectedHTTPResponse := http.StatusOK + if !exists { + expectedHTTPResponse = http.StatusNotFound + } + session := loginUser(t, "user1") + req := NewRequest(t, "GET", "/admin/self_check") + resp := session.MakeRequest(t, req, expectedHTTPResponse) + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, "a.item[href*='/admin/self_check']", exists) + } + + if setting.Database.Type.IsMySQL() { + assertSelfCheckExists(true) + } else { + assertSelfCheckExists(false) + } +} + +func TestDatabaseCollation(t *testing.T) { + x := db.GetEngine(db.DefaultContext).(*xorm.Engine) + + // all created tables should use case-sensitive collation by default + _, _ = x.Exec("DROP TABLE IF EXISTS test_collation_tbl") + err := x.Sync(&TestCollationTbl{}) + require.NoError(t, err) + _, _ = x.Exec("INSERT INTO test_collation_tbl (txt) VALUES ('main')") + _, _ = x.Exec("INSERT INTO test_collation_tbl (txt) VALUES ('Main')") // case-sensitive, so it inserts a new row + _, _ = x.Exec("INSERT INTO test_collation_tbl (txt) VALUES ('main')") // duplicate, so it doesn't insert + cnt, err := x.Count(&TestCollationTbl{}) + require.NoError(t, err) + assert.EqualValues(t, 2, cnt) + _, _ = x.Exec("DROP TABLE IF EXISTS test_collation_tbl") + + // by default, SQLite3 and PostgreSQL are using case-sensitive collations, but MySQL is not. + if !setting.Database.Type.IsMySQL() { + t.Skip("only MySQL requires the case-sensitive collation check at the moment") + return + } + + t.Run("Default startup makes database collation case-sensitive", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + r, err := db.CheckCollations(x) + require.NoError(t, err) + assert.True(t, r.IsCollationCaseSensitive(r.DatabaseCollation)) + assert.True(t, r.CollationEquals(r.ExpectedCollation, r.DatabaseCollation)) + assert.NotEmpty(t, r.AvailableCollation) + assert.Empty(t, r.InconsistentCollationColumns) + + // and by the way test the helper functions + if setting.Database.Type.IsMySQL() { + assert.True(t, r.IsCollationCaseSensitive("utf8mb4_bin")) + assert.True(t, r.IsCollationCaseSensitive("utf8mb4_xxx_as_cs")) + assert.False(t, r.IsCollationCaseSensitive("utf8mb4_general_ci")) + assert.True(t, r.CollationEquals("abc", "abc")) + assert.True(t, r.CollationEquals("abc", "utf8mb4_abc")) + assert.False(t, r.CollationEquals("utf8mb4_general_ci", "utf8mb4_unicode_ci")) + } else { + assert.Fail(t, "unexpected database type") + } + }) + + t.Run("Convert tables to utf8mb4_bin", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + defer test.MockVariableValue(&setting.Database.CharsetCollation, "utf8mb4_bin")() + require.NoError(t, db.ConvertDatabaseTable()) + time.Sleep(5 * time.Second) + + r, err := db.CheckCollations(x) + require.NoError(t, err) + assert.Equal(t, "utf8mb4_bin", r.DatabaseCollation) + assert.True(t, r.CollationEquals(r.ExpectedCollation, r.DatabaseCollation)) + assert.Empty(t, r.InconsistentCollationColumns) + + _, _ = x.Exec("DROP TABLE IF EXISTS test_tbl") + _, err = x.Exec("CREATE TABLE test_tbl (txt varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL)") + require.NoError(t, err) + r, err = db.CheckCollations(x) + require.NoError(t, err) + assert.Contains(t, r.InconsistentCollationColumns, "test_tbl.txt") + }) + + t.Run("Convert tables to utf8mb4_general_ci", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + defer test.MockVariableValue(&setting.Database.CharsetCollation, "utf8mb4_general_ci")() + require.NoError(t, db.ConvertDatabaseTable()) + time.Sleep(5 * time.Second) + + r, err := db.CheckCollations(x) + require.NoError(t, err) + assert.Equal(t, "utf8mb4_general_ci", r.DatabaseCollation) + assert.True(t, r.CollationEquals(r.ExpectedCollation, r.DatabaseCollation)) + assert.Empty(t, r.InconsistentCollationColumns) + + _, _ = x.Exec("DROP TABLE IF EXISTS test_tbl") + _, err = x.Exec("CREATE TABLE test_tbl (txt varchar(10) COLLATE utf8mb4_bin NOT NULL)") + require.NoError(t, err) + r, err = db.CheckCollations(x) + require.NoError(t, err) + assert.Contains(t, r.InconsistentCollationColumns, "test_tbl.txt") + }) + + t.Run("Convert tables to default case-sensitive collation", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + defer test.MockVariableValue(&setting.Database.CharsetCollation, "")() + require.NoError(t, db.ConvertDatabaseTable()) + time.Sleep(5 * time.Second) + + r, err := db.CheckCollations(x) + require.NoError(t, err) + assert.True(t, r.IsCollationCaseSensitive(r.DatabaseCollation)) + assert.True(t, r.CollationEquals(r.ExpectedCollation, r.DatabaseCollation)) + assert.Empty(t, r.InconsistentCollationColumns) + }) +} diff --git a/tests/integration/delete_user_test.go b/tests/integration/delete_user_test.go new file mode 100644 index 0000000..fa407a7 --- /dev/null +++ b/tests/integration/delete_user_test.go @@ -0,0 +1,63 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/tests" +) + +func assertUserDeleted(t *testing.T, userID int64, purged bool) { + unittest.AssertNotExistsBean(t, &user_model.User{ID: userID}) + unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: userID}) + unittest.AssertNotExistsBean(t, &user_model.Follow{FollowID: userID}) + unittest.AssertNotExistsBean(t, &repo_model.Repository{OwnerID: userID}) + unittest.AssertNotExistsBean(t, &access_model.Access{UserID: userID}) + unittest.AssertNotExistsBean(t, &organization.OrgUser{UID: userID}) + unittest.AssertNotExistsBean(t, &issues_model.IssueUser{UID: userID}) + unittest.AssertNotExistsBean(t, &organization.TeamUser{UID: userID}) + unittest.AssertNotExistsBean(t, &repo_model.Star{UID: userID}) + if purged { + unittest.AssertNotExistsBean(t, &issues_model.Issue{PosterID: userID}) + } +} + +func TestUserDeleteAccount(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user8") + csrf := GetCSRF(t, session, "/user/settings/account") + urlStr := fmt.Sprintf("/user/settings/account/delete?password=%s", userPassword) + req := NewRequestWithValues(t, "POST", urlStr, map[string]string{ + "_csrf": csrf, + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + assertUserDeleted(t, 8, false) + unittest.CheckConsistencyFor(t, &user_model.User{}) +} + +func TestUserDeleteAccountStillOwnRepos(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + csrf := GetCSRF(t, session, "/user/settings/account") + urlStr := fmt.Sprintf("/user/settings/account/delete?password=%s", userPassword) + req := NewRequestWithValues(t, "POST", urlStr, map[string]string{ + "_csrf": csrf, + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + // user should not have been deleted, because the user still owns repos + unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) +} diff --git a/tests/integration/doctor_packages_nuget_test.go b/tests/integration/doctor_packages_nuget_test.go new file mode 100644 index 0000000..a012567 --- /dev/null +++ b/tests/integration/doctor_packages_nuget_test.go @@ -0,0 +1,122 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + packages_module "code.gitea.io/gitea/modules/packages" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + doctor "code.gitea.io/gitea/services/doctor" + packages_service "code.gitea.io/gitea/services/packages" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDoctorPackagesNuget(t *testing.T) { + defer tests.PrepareTestEnv(t, 1)() + // use local storage for tests because minio is too flaky + defer test.MockVariableValue(&setting.Packages.Storage.Type, setting.LocalStorageType)() + + logger := log.GetLogger("doctor") + + ctx := db.DefaultContext + + packageName := "test.package" + packageVersion := "1.0.3" + packageAuthors := "KN4CK3R" + packageDescription := "Gitea Test Package" + + createPackage := func(id, version string) io.Reader { + var buf bytes.Buffer + archive := zip.NewWriter(&buf) + w, _ := archive.Create("package.nuspec") + w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?> + <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"> + <metadata> + <id>` + id + `</id> + <version>` + version + `</version> + <authors>` + packageAuthors + `</authors> + <description>` + packageDescription + `</description> + <dependencies> + <group targetFramework=".NETStandard2.0"> + <dependency id="Microsoft.CSharp" version="4.5.0" /> + </group> + </dependencies> + </metadata> + </package>`)) + archive.Close() + return &buf + } + + pkg := createPackage(packageName, packageVersion) + + pkgBuf, err := packages_module.CreateHashedBufferFromReader(pkg) + require.NoError(t, err, "Error creating hashed buffer from nupkg") + defer pkgBuf.Close() + + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + require.NoError(t, err, "Error getting user by ID 2") + + t.Run("PackagesNugetNuspecCheck", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + pi := &packages_service.PackageInfo{ + Owner: doer, + PackageType: packages_model.TypeNuGet, + Name: packageName, + Version: packageVersion, + } + _, _, err := packages_service.CreatePackageAndAddFile( + ctx, + &packages_service.PackageCreationInfo{ + PackageInfo: *pi, + SemverCompatible: true, + Creator: doer, + Metadata: nil, + }, + &packages_service.PackageFileCreationInfo{ + PackageFileInfo: packages_service.PackageFileInfo{ + Filename: strings.ToLower(fmt.Sprintf("%s.%s.nupkg", packageName, packageVersion)), + }, + Creator: doer, + Data: pkgBuf, + IsLead: true, + }, + ) + require.NoError(t, err, "Error creating package and adding file") + + require.NoError(t, doctor.PackagesNugetNuspecCheck(ctx, logger, true), "Doctor check failed") + + s, _, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + ctx, + &packages_service.PackageInfo{ + Owner: doer, + PackageType: packages_model.TypeNuGet, + Name: packageName, + Version: packageVersion, + }, + &packages_service.PackageFileInfo{ + Filename: strings.ToLower(fmt.Sprintf("%s.nuspec", packageName)), + }, + ) + + require.NoError(t, err, "Error getting nuspec file stream by package name and version") + defer s.Close() + + assert.Equal(t, fmt.Sprintf("%s.nuspec", packageName), pf.Name, "Not a nuspec") + }) +} diff --git a/tests/integration/download_test.go b/tests/integration/download_test.go new file mode 100644 index 0000000..efe5ac7 --- /dev/null +++ b/tests/integration/download_test.go @@ -0,0 +1,93 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestDownloadByID(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + // Request raw blob + req := NewRequest(t, "GET", "/user2/repo1/raw/blob/4b4851ad51df6a7d9f25c979345979eaeb5b349f") + resp := session.MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "# repo1\n\nDescription for repo1", resp.Body.String()) +} + +func TestDownloadByIDForSVGUsesSecureHeaders(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + // Request raw blob + req := NewRequest(t, "GET", "/user2/repo2/raw/blob/6395b68e1feebb1e4c657b4f9f6ba2676a283c0b") + resp := session.MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "default-src 'none'; style-src 'unsafe-inline'; sandbox", resp.Header().Get("Content-Security-Policy")) + assert.Equal(t, "image/svg+xml", resp.Header().Get("Content-Type")) + assert.Equal(t, "nosniff", resp.Header().Get("X-Content-Type-Options")) +} + +func TestDownloadByIDMedia(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + // Request raw blob + req := NewRequest(t, "GET", "/user2/repo1/media/blob/4b4851ad51df6a7d9f25c979345979eaeb5b349f") + resp := session.MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "# repo1\n\nDescription for repo1", resp.Body.String()) +} + +func TestDownloadByIDMediaForSVGUsesSecureHeaders(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + // Request raw blob + req := NewRequest(t, "GET", "/user2/repo2/media/blob/6395b68e1feebb1e4c657b4f9f6ba2676a283c0b") + resp := session.MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "default-src 'none'; style-src 'unsafe-inline'; sandbox", resp.Header().Get("Content-Security-Policy")) + assert.Equal(t, "image/svg+xml", resp.Header().Get("Content-Type")) + assert.Equal(t, "nosniff", resp.Header().Get("X-Content-Type-Options")) +} + +func TestDownloadRawTextFileWithoutMimeTypeMapping(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user2/repo2/raw/branch/master/test.xml") + resp := session.MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type")) +} + +func TestDownloadRawTextFileWithMimeTypeMapping(t *testing.T) { + defer tests.PrepareTestEnv(t)() + setting.MimeTypeMap.Map[".xml"] = "text/xml" + setting.MimeTypeMap.Enabled = true + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user2/repo2/raw/branch/master/test.xml") + resp := session.MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "text/xml; charset=utf-8", resp.Header().Get("Content-Type")) + + delete(setting.MimeTypeMap.Map, ".xml") + setting.MimeTypeMap.Enabled = false +} diff --git a/tests/integration/dump_restore_test.go b/tests/integration/dump_restore_test.go new file mode 100644 index 0000000..fa65695 --- /dev/null +++ b/tests/integration/dump_restore_test.go @@ -0,0 +1,329 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + base "code.gitea.io/gitea/modules/migration" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/migrations" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestDumpRestore(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + AllowLocalNetworks := setting.Migrations.AllowLocalNetworks + setting.Migrations.AllowLocalNetworks = true + AppVer := setting.AppVer + // Gitea SDK (go-sdk) need to parse the AppVer from server response, so we must set it to a valid version string. + setting.AppVer = "1.16.0" + defer func() { + setting.Migrations.AllowLocalNetworks = AllowLocalNetworks + setting.AppVer = AppVer + }() + + require.NoError(t, migrations.Init()) + + reponame := "repo1" + + basePath, err := os.MkdirTemp("", reponame) + require.NoError(t, err) + defer util.RemoveAll(basePath) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: reponame}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeReadMisc) + + // + // Phase 1: dump repo1 from the Gitea instance to the filesystem + // + + ctx := context.Background() + opts := migrations.MigrateOptions{ + GitServiceType: structs.GiteaService, + Issues: true, + PullRequests: true, + Labels: true, + Milestones: true, + Comments: true, + AuthToken: token, + CloneAddr: repo.CloneLink().HTTPS, + RepoName: reponame, + } + err = migrations.DumpRepository(ctx, basePath, repoOwner.Name, opts) + require.NoError(t, err) + + // + // Verify desired side effects of the dump + // + d := filepath.Join(basePath, repo.OwnerName, repo.Name) + for _, f := range []string{"repo.yml", "topic.yml", "label.yml", "milestone.yml", "issue.yml"} { + assert.FileExists(t, filepath.Join(d, f)) + } + + // + // Phase 2: restore from the filesystem to the Gitea instance in restoredrepo + // + + newreponame := "restored" + err = migrations.RestoreRepository(ctx, d, repo.OwnerName, newreponame, []string{ + "labels", "issues", "comments", "milestones", "pull_requests", + }, false) + require.NoError(t, err) + + newrepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: newreponame}) + + // + // Phase 3: dump restored from the Gitea instance to the filesystem + // + opts.RepoName = newreponame + opts.CloneAddr = newrepo.CloneLink().HTTPS + err = migrations.DumpRepository(ctx, basePath, repoOwner.Name, opts) + require.NoError(t, err) + + // + // Verify the dump of restored is the same as the dump of repo1 + // + comparator := &compareDump{ + t: t, + basePath: basePath, + } + comparator.assertEquals(repo, newrepo) + }) +} + +type compareDump struct { + t *testing.T + basePath string + repoBefore *repo_model.Repository + dirBefore string + repoAfter *repo_model.Repository + dirAfter string +} + +type compareField struct { + before any + after any + ignore bool + transform func(string) string + nested *compareFields +} + +type compareFields map[string]compareField + +func (c *compareDump) replaceRepoName(original string) string { + return strings.ReplaceAll(original, c.repoBefore.Name, c.repoAfter.Name) +} + +func (c *compareDump) assertEquals(repoBefore, repoAfter *repo_model.Repository) { + c.repoBefore = repoBefore + c.dirBefore = filepath.Join(c.basePath, repoBefore.OwnerName, repoBefore.Name) + c.repoAfter = repoAfter + c.dirAfter = filepath.Join(c.basePath, repoAfter.OwnerName, repoAfter.Name) + + // + // base.Repository + // + _ = c.assertEqual("repo.yml", base.Repository{}, compareFields{ + "Name": { + before: c.repoBefore.Name, + after: c.repoAfter.Name, + }, + "CloneURL": {transform: c.replaceRepoName}, + "OriginalURL": {transform: c.replaceRepoName}, + }) + + // + // base.Label + // + labels, ok := c.assertEqual("label.yml", []base.Label{}, compareFields{}).([]*base.Label) + assert.True(c.t, ok) + assert.GreaterOrEqual(c.t, len(labels), 1) + + // + // base.Milestone + // + milestones, ok := c.assertEqual("milestone.yml", []base.Milestone{}, compareFields{ + "Updated": {ignore: true}, // the database updates that field independently + }).([]*base.Milestone) + assert.True(c.t, ok) + assert.GreaterOrEqual(c.t, len(milestones), 1) + + // + // base.Issue and the associated comments + // + issues, ok := c.assertEqual("issue.yml", []base.Issue{}, compareFields{ + "Assignees": {ignore: true}, // not implemented yet + }).([]*base.Issue) + assert.True(c.t, ok) + assert.GreaterOrEqual(c.t, len(issues), 1) + for _, issue := range issues { + filename := filepath.Join("comments", fmt.Sprintf("%d.yml", issue.Number)) + comments, ok := c.assertEqual(filename, []base.Comment{}, compareFields{ + "Index": {ignore: true}, + }).([]*base.Comment) + assert.True(c.t, ok) + for _, comment := range comments { + assert.EqualValues(c.t, issue.Number, comment.IssueIndex) + } + } + + // + // base.PullRequest and the associated comments + // + comparePullRequestBranch := &compareFields{ + "RepoName": { + before: c.repoBefore.Name, + after: c.repoAfter.Name, + }, + "CloneURL": {transform: c.replaceRepoName}, + } + prs, ok := c.assertEqual("pull_request.yml", []base.PullRequest{}, compareFields{ + "Assignees": {ignore: true}, // not implemented yet + "Head": {nested: comparePullRequestBranch}, + "Base": {nested: comparePullRequestBranch}, + "Labels": {ignore: true}, // because org labels are not handled properly + }).([]*base.PullRequest) + assert.True(c.t, ok) + assert.GreaterOrEqual(c.t, len(prs), 1) + for _, pr := range prs { + filename := filepath.Join("comments", fmt.Sprintf("%d.yml", pr.Number)) + comments, ok := c.assertEqual(filename, []base.Comment{}, compareFields{}).([]*base.Comment) + assert.True(c.t, ok) + for _, comment := range comments { + assert.EqualValues(c.t, pr.Number, comment.IssueIndex) + } + } +} + +func (c *compareDump) assertLoadYAMLFiles(beforeFilename, afterFilename string, before, after any) { + _, beforeErr := os.Stat(beforeFilename) + _, afterErr := os.Stat(afterFilename) + assert.EqualValues(c.t, errors.Is(beforeErr, os.ErrNotExist), errors.Is(afterErr, os.ErrNotExist)) + if errors.Is(beforeErr, os.ErrNotExist) { + return + } + + beforeBytes, err := os.ReadFile(beforeFilename) + require.NoError(c.t, err) + require.NoError(c.t, yaml.Unmarshal(beforeBytes, before)) + afterBytes, err := os.ReadFile(afterFilename) + require.NoError(c.t, err) + require.NoError(c.t, yaml.Unmarshal(afterBytes, after)) +} + +func (c *compareDump) assertLoadFiles(beforeFilename, afterFilename string, t reflect.Type) (before, after reflect.Value) { + var beforePtr, afterPtr reflect.Value + if t.Kind() == reflect.Slice { + // + // Given []Something{} create afterPtr, beforePtr []*Something{} + // + sliceType := reflect.SliceOf(reflect.PointerTo(t.Elem())) + beforeSlice := reflect.MakeSlice(sliceType, 0, 10) + beforePtr = reflect.New(beforeSlice.Type()) + beforePtr.Elem().Set(beforeSlice) + afterSlice := reflect.MakeSlice(sliceType, 0, 10) + afterPtr = reflect.New(afterSlice.Type()) + afterPtr.Elem().Set(afterSlice) + } else { + // + // Given Something{} create afterPtr, beforePtr *Something{} + // + beforePtr = reflect.New(t) + afterPtr = reflect.New(t) + } + c.assertLoadYAMLFiles(beforeFilename, afterFilename, beforePtr.Interface(), afterPtr.Interface()) + return beforePtr.Elem(), afterPtr.Elem() +} + +func (c *compareDump) assertEqual(filename string, kind any, fields compareFields) (i any) { + beforeFilename := filepath.Join(c.dirBefore, filename) + afterFilename := filepath.Join(c.dirAfter, filename) + + typeOf := reflect.TypeOf(kind) + before, after := c.assertLoadFiles(beforeFilename, afterFilename, typeOf) + if typeOf.Kind() == reflect.Slice { + i = c.assertEqualSlices(before, after, fields) + } else { + i = c.assertEqualValues(before, after, fields) + } + return i +} + +func (c *compareDump) assertEqualSlices(before, after reflect.Value, fields compareFields) any { + assert.EqualValues(c.t, before.Len(), after.Len()) + if before.Len() == after.Len() { + for i := 0; i < before.Len(); i++ { + _ = c.assertEqualValues( + reflect.Indirect(before.Index(i).Elem()), + reflect.Indirect(after.Index(i).Elem()), + fields) + } + } + return after.Interface() +} + +func (c *compareDump) assertEqualValues(before, after reflect.Value, fields compareFields) any { + for _, field := range reflect.VisibleFields(before.Type()) { + bf := before.FieldByName(field.Name) + bi := bf.Interface() + af := after.FieldByName(field.Name) + ai := af.Interface() + if compare, ok := fields[field.Name]; ok { + if compare.ignore == true { + // + // Ignore + // + continue + } + if compare.transform != nil { + // + // Transform these strings before comparing them + // + bs, ok := bi.(string) + assert.True(c.t, ok) + as, ok := ai.(string) + assert.True(c.t, ok) + assert.EqualValues(c.t, compare.transform(bs), compare.transform(as)) + continue + } + if compare.before != nil && compare.after != nil { + // + // The fields are expected to have different values + // + assert.EqualValues(c.t, compare.before, bi) + assert.EqualValues(c.t, compare.after, ai) + continue + } + if compare.nested != nil { + // + // The fields are a struct, recurse + // + c.assertEqualValues(bf, af, *compare.nested) + continue + } + } + assert.EqualValues(c.t, bi, ai) + } + return after.Interface() +} diff --git a/tests/integration/easymde_test.go b/tests/integration/easymde_test.go new file mode 100644 index 0000000..c8203d3 --- /dev/null +++ b/tests/integration/easymde_test.go @@ -0,0 +1,25 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" +) + +func TestEasyMDESwitch(t *testing.T) { + session := loginUser(t, "user2") + testEasyMDESwitch(t, session, "user2/glob/issues/1", false) + testEasyMDESwitch(t, session, "user2/glob/issues/new", false) + testEasyMDESwitch(t, session, "user2/glob/wiki?action=_new", true) + testEasyMDESwitch(t, session, "user2/glob/releases/new", true) +} + +func testEasyMDESwitch(t *testing.T, session *TestSession, url string, expected bool) { + t.Helper() + req := NewRequest(t, "GET", url) + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + doc.AssertElement(t, ".combo-markdown-editor button.markdown-switch-easymde", expected) +} diff --git a/tests/integration/editor_test.go b/tests/integration/editor_test.go new file mode 100644 index 0000000..4ed6485 --- /dev/null +++ b/tests/integration/editor_test.go @@ -0,0 +1,519 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "path" + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/translation" + gitea_context "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateFile(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user2") + testCreateFile(t, session, "user2", "repo1", "master", "test.txt", "Content") + }) +} + +func testCreateFile(t *testing.T, session *TestSession, user, repo, branch, filePath, content string) *httptest.ResponseRecorder { + // Request editor page + newURL := fmt.Sprintf("/%s/%s/_new/%s/", user, repo, branch) + req := NewRequest(t, "GET", newURL) + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + lastCommit := doc.GetInputValueByName("last_commit") + assert.NotEmpty(t, lastCommit) + + // Save new file to master branch + req = NewRequestWithValues(t, "POST", newURL, map[string]string{ + "_csrf": doc.GetCSRF(), + "last_commit": lastCommit, + "tree_path": filePath, + "content": content, + "commit_choice": "direct", + "commit_mail_id": "3", + }) + return session.MakeRequest(t, req, http.StatusSeeOther) +} + +func TestCreateFileOnProtectedBranch(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user2") + + csrf := GetCSRF(t, session, "/user2/repo1/settings/branches") + // Change master branch to protected + req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{ + "_csrf": csrf, + "rule_name": "master", + "enable_push": "true", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + // Check if master branch has been locked successfully + flashCookie := session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "success%3DBranch%2Bprotection%2Bfor%2Brule%2B%2522master%2522%2Bhas%2Bbeen%2Bupdated.", flashCookie.Value) + + // Request editor page + req = NewRequest(t, "GET", "/user2/repo1/_new/master/") + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + lastCommit := doc.GetInputValueByName("last_commit") + assert.NotEmpty(t, lastCommit) + + // Save new file to master branch + req = NewRequestWithValues(t, "POST", "/user2/repo1/_new/master/", map[string]string{ + "_csrf": doc.GetCSRF(), + "last_commit": lastCommit, + "tree_path": "test.txt", + "content": "Content", + "commit_choice": "direct", + "commit_mail_id": "3", + }) + + resp = session.MakeRequest(t, req, http.StatusOK) + // Check body for error message + assert.Contains(t, resp.Body.String(), "Cannot commit to protected branch "master".") + + // remove the protected branch + csrf = GetCSRF(t, session, "/user2/repo1/settings/branches") + + // Change master branch to protected + req = NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/1/delete", map[string]string{ + "_csrf": csrf, + }) + + resp = session.MakeRequest(t, req, http.StatusOK) + + res := make(map[string]string) + require.NoError(t, json.NewDecoder(resp.Body).Decode(&res)) + assert.EqualValues(t, "/user2/repo1/settings/branches", res["redirect"]) + + // Check if master branch has been locked successfully + flashCookie = session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "error%3DRemoving%2Bbranch%2Bprotection%2Brule%2B%25221%2522%2Bfailed.", flashCookie.Value) + }) +} + +func testEditFile(t *testing.T, session *TestSession, user, repo, branch, filePath, newContent string) *httptest.ResponseRecorder { + // Get to the 'edit this file' page + req := NewRequest(t, "GET", path.Join(user, repo, "_edit", branch, filePath)) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + lastCommit := htmlDoc.GetInputValueByName("last_commit") + assert.NotEmpty(t, lastCommit) + + // Submit the edits + req = NewRequestWithValues(t, "POST", path.Join(user, repo, "_edit", branch, filePath), + map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "last_commit": lastCommit, + "tree_path": filePath, + "content": newContent, + "commit_choice": "direct", + "commit_mail_id": "-1", + }, + ) + session.MakeRequest(t, req, http.StatusSeeOther) + + // Verify the change + req = NewRequest(t, "GET", path.Join(user, repo, "raw/branch", branch, filePath)) + resp = session.MakeRequest(t, req, http.StatusOK) + assert.EqualValues(t, newContent, resp.Body.String()) + + return resp +} + +func testEditFileToNewBranch(t *testing.T, session *TestSession, user, repo, branch, targetBranch, filePath, newContent string) *httptest.ResponseRecorder { + // Get to the 'edit this file' page + req := NewRequest(t, "GET", path.Join(user, repo, "_edit", branch, filePath)) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + lastCommit := htmlDoc.GetInputValueByName("last_commit") + assert.NotEmpty(t, lastCommit) + + // Submit the edits + req = NewRequestWithValues(t, "POST", path.Join(user, repo, "_edit", branch, filePath), + map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "last_commit": lastCommit, + "tree_path": filePath, + "content": newContent, + "commit_choice": "commit-to-new-branch", + "new_branch_name": targetBranch, + "commit_mail_id": "-1", + }, + ) + session.MakeRequest(t, req, http.StatusSeeOther) + + // Verify the change + req = NewRequest(t, "GET", path.Join(user, repo, "raw/branch", targetBranch, filePath)) + resp = session.MakeRequest(t, req, http.StatusOK) + assert.EqualValues(t, newContent, resp.Body.String()) + + return resp +} + +func TestEditFile(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user2") + testEditFile(t, session, "user2", "repo1", "master", "README.md", "Hello, World (Edited)\n") + }) +} + +func TestEditFileToNewBranch(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user2") + testEditFileToNewBranch(t, session, "user2", "repo1", "master", "feature/test", "README.md", "Hello, World (Edited)\n") + }) +} + +func TestEditorAddTranslation(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user2/repo1/_new/master") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + placeholder, ok := htmlDoc.Find("input[name='commit_summary']").Attr("placeholder") + assert.True(t, ok) + assert.EqualValues(t, `Add "<filename>"`, placeholder) +} + +func TestCommitMail(t *testing.T) { + onGiteaRun(t, func(t *testing.T, _ *url.URL) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + // Require that the user has KeepEmailPrivate enabled, because it needs + // to be tested that even with this setting enabled, it will use the + // provided mail and not revert to the placeholder one. + assert.True(t, user.KeepEmailPrivate) + + inactivatedMail := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 35, UID: user.ID}) + assert.False(t, inactivatedMail.IsActivated) + + otherEmail := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 1, IsActivated: true}) + assert.NotEqualValues(t, otherEmail.UID, user.ID) + + primaryEmail := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 3, UID: user.ID, IsActivated: true}) + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + gitRepo, _ := git.OpenRepository(git.DefaultContext, repo1.RepoPath()) + defer gitRepo.Close() + + session := loginUser(t, user.Name) + + lastCommitAndCSRF := func(t *testing.T, link string, skipLastCommit bool) (string, string) { + t.Helper() + + req := NewRequest(t, "GET", link) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + lastCommit := htmlDoc.GetInputValueByName("last_commit") + if !skipLastCommit { + assert.NotEmpty(t, lastCommit) + } + + return lastCommit, htmlDoc.GetCSRF() + } + + type caseOpts struct { + link string + fileName string + base map[string]string + skipLastCommit bool + } + + // Base2 should have different content, so we can test two 'correct' operations + // without the second becoming a noop because no content was changed. If needed, + // link2 can point to a new file that's used with base2. + assertCase := func(t *testing.T, case1, case2 caseOpts) { + t.Helper() + + t.Run("Not activated", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + lastCommit, csrf := lastCommitAndCSRF(t, case1.link, case1.skipLastCommit) + baseCopy := case1.base + baseCopy["_csrf"] = csrf + baseCopy["last_commit"] = lastCommit + baseCopy["commit_mail_id"] = fmt.Sprintf("%d", inactivatedMail.ID) + + req := NewRequestWithValues(t, "POST", case1.link, baseCopy) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, + htmlDoc.doc.Find(".ui.negative.message").Text(), + translation.NewLocale("en-US").Tr("repo.editor.invalid_commit_mail"), + ) + }) + + t.Run("Not belong to user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + lastCommit, csrf := lastCommitAndCSRF(t, case1.link, case1.skipLastCommit) + baseCopy := case1.base + baseCopy["_csrf"] = csrf + baseCopy["last_commit"] = lastCommit + baseCopy["commit_mail_id"] = fmt.Sprintf("%d", otherEmail.ID) + + req := NewRequestWithValues(t, "POST", case1.link, baseCopy) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, + htmlDoc.doc.Find(".ui.negative.message").Text(), + translation.NewLocale("en-US").Tr("repo.editor.invalid_commit_mail"), + ) + }) + + t.Run("Placeholder mail", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + lastCommit, csrf := lastCommitAndCSRF(t, case1.link, case1.skipLastCommit) + baseCopy := case1.base + baseCopy["_csrf"] = csrf + baseCopy["last_commit"] = lastCommit + baseCopy["commit_mail_id"] = "-1" + + req := NewRequestWithValues(t, "POST", case1.link, baseCopy) + session.MakeRequest(t, req, http.StatusSeeOther) + if !case2.skipLastCommit { + newlastCommit, _ := lastCommitAndCSRF(t, case1.link, false) + assert.NotEqualValues(t, newlastCommit, lastCommit) + } + + commit, err := gitRepo.GetCommitByPath(case1.fileName) + require.NoError(t, err) + + assert.EqualValues(t, "user2", commit.Author.Name) + assert.EqualValues(t, "user2@noreply.example.org", commit.Author.Email) + assert.EqualValues(t, "user2", commit.Committer.Name) + assert.EqualValues(t, "user2@noreply.example.org", commit.Committer.Email) + }) + + t.Run("Normal", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + lastCommit, csrf := lastCommitAndCSRF(t, case2.link, case2.skipLastCommit) + baseCopy := case2.base + baseCopy["_csrf"] = csrf + baseCopy["last_commit"] = lastCommit + baseCopy["commit_mail_id"] = fmt.Sprintf("%d", primaryEmail.ID) + + req := NewRequestWithValues(t, "POST", case2.link, baseCopy) + session.MakeRequest(t, req, http.StatusSeeOther) + if !case2.skipLastCommit { + newlastCommit, _ := lastCommitAndCSRF(t, case2.link, false) + assert.NotEqualValues(t, newlastCommit, lastCommit) + } + + commit, err := gitRepo.GetCommitByPath(case2.fileName) + require.NoError(t, err) + + assert.EqualValues(t, "user2", commit.Author.Name) + assert.EqualValues(t, primaryEmail.Email, commit.Author.Email) + assert.EqualValues(t, "user2", commit.Committer.Name) + assert.EqualValues(t, primaryEmail.Email, commit.Committer.Email) + }) + } + + t.Run("New", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + assertCase(t, caseOpts{ + fileName: "new_file", + link: "user2/repo1/_new/master", + base: map[string]string{ + "tree_path": "new_file", + "content": "new_content", + "commit_choice": "direct", + }, + }, caseOpts{ + fileName: "new_file_2", + link: "user2/repo1/_new/master", + base: map[string]string{ + "tree_path": "new_file_2", + "content": "new_content", + "commit_choice": "direct", + }, + }, + ) + }) + + t.Run("Edit", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + assertCase(t, caseOpts{ + fileName: "README.md", + link: "user2/repo1/_edit/master/README.md", + base: map[string]string{ + "tree_path": "README.md", + "content": "Edit content", + "commit_choice": "direct", + }, + }, caseOpts{ + fileName: "README.md", + link: "user2/repo1/_edit/master/README.md", + base: map[string]string{ + "tree_path": "README.md", + "content": "Other content", + "commit_choice": "direct", + }, + }, + ) + }) + + t.Run("Delete", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + assertCase(t, caseOpts{ + fileName: "new_file", + link: "user2/repo1/_delete/master/new_file", + base: map[string]string{ + "commit_choice": "direct", + }, + }, caseOpts{ + fileName: "new_file_2", + link: "user2/repo1/_delete/master/new_file_2", + base: map[string]string{ + "commit_choice": "direct", + }, + }, + ) + }) + + t.Run("Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Upload two separate times, so we have two different 'uploads' that can + // be used independently of each other. + uploadFile := func(t *testing.T, name, content string) string { + t.Helper() + + body := &bytes.Buffer{} + mpForm := multipart.NewWriter(body) + err := mpForm.WriteField("_csrf", GetCSRF(t, session, "/user2/repo1/_upload/master")) + require.NoError(t, err) + + file, err := mpForm.CreateFormFile("file", name) + require.NoError(t, err) + + io.Copy(file, bytes.NewBufferString(content)) + require.NoError(t, mpForm.Close()) + + req := NewRequestWithBody(t, "POST", "/user2/repo1/upload-file", body) + req.Header.Add("Content-Type", mpForm.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusOK) + + respMap := map[string]string{} + DecodeJSON(t, resp, &respMap) + return respMap["uuid"] + } + + file1UUID := uploadFile(t, "upload_file_1", "Uploaded a file!") + file2UUID := uploadFile(t, "upload_file_2", "Uploaded another file!") + + assertCase(t, caseOpts{ + fileName: "upload_file_1", + link: "user2/repo1/_upload/master", + skipLastCommit: true, + base: map[string]string{ + "commit_choice": "direct", + "files": file1UUID, + }, + }, caseOpts{ + fileName: "upload_file_2", + link: "user2/repo1/_upload/master", + skipLastCommit: true, + base: map[string]string{ + "commit_choice": "direct", + "files": file2UUID, + }, + }, + ) + }) + + t.Run("Apply patch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + assertCase(t, caseOpts{ + fileName: "diff-file-1.txt", + link: "user2/repo1/_diffpatch/master", + base: map[string]string{ + "tree_path": "patch", + "commit_choice": "direct", + "content": `diff --git a/diff-file-1.txt b/diff-file-1.txt +new file mode 100644 +index 0000000000..50fcd26d6c +--- /dev/null ++++ b/diff-file-1.txt +@@ -0,0 +1 @@ ++File 1 +`, + }, + }, caseOpts{ + fileName: "diff-file-2.txt", + link: "user2/repo1/_diffpatch/master", + base: map[string]string{ + "tree_path": "patch", + "commit_choice": "direct", + "content": `diff --git a/diff-file-2.txt b/diff-file-2.txt +new file mode 100644 +index 0000000000..4475433e27 +--- /dev/null ++++ b/diff-file-2.txt +@@ -0,0 +1 @@ ++File 2 +`, + }, + }) + }) + + t.Run("Cherry pick", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + commitID1, err := gitRepo.GetCommitByPath("diff-file-1.txt") + require.NoError(t, err) + commitID2, err := gitRepo.GetCommitByPath("diff-file-2.txt") + require.NoError(t, err) + + assertCase(t, caseOpts{ + fileName: "diff-file-1.txt", + link: "user2/repo1/_cherrypick/" + commitID1.ID.String() + "/master", + base: map[string]string{ + "commit_choice": "direct", + "revert": "true", + }, + }, caseOpts{ + fileName: "diff-file-2.txt", + link: "user2/repo1/_cherrypick/" + commitID2.ID.String() + "/master", + base: map[string]string{ + "commit_choice": "direct", + "revert": "true", + }, + }) + }) + }) +} diff --git a/tests/integration/empty_repo_test.go b/tests/integration/empty_repo_test.go new file mode 100644 index 0000000..4122c78 --- /dev/null +++ b/tests/integration/empty_repo_test.go @@ -0,0 +1,138 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "encoding/base64" + "io" + "mime/multipart" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEmptyRepo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + subPaths := []string{ + "commits/master", + "raw/foo", + "commit/1ae57b34ccf7e18373", + "graph", + } + emptyRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 6}) + assert.True(t, emptyRepo.IsEmpty) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: emptyRepo.OwnerID}) + for _, subPath := range subPaths { + req := NewRequestf(t, "GET", "/%s/%s/%s", owner.Name, emptyRepo.Name, subPath) + MakeRequest(t, req, http.StatusNotFound) + } +} + +func TestEmptyRepoAddFile(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user30") + req := NewRequest(t, "GET", "/user30/empty/_new/"+setting.Repository.DefaultBranch) + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body).Find(`input[name="commit_choice"]`) + assert.Empty(t, doc.AttrOr("checked", "_no_")) + req = NewRequestWithValues(t, "POST", "/user30/empty/_new/"+setting.Repository.DefaultBranch, map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings"), + "commit_choice": "direct", + "tree_path": "test-file.md", + "content": "newly-added-test-file", + "commit_mail_id": "32", + }) + + resp = session.MakeRequest(t, req, http.StatusSeeOther) + redirect := test.RedirectURL(resp) + assert.Equal(t, "/user30/empty/src/branch/"+setting.Repository.DefaultBranch+"/test-file.md", redirect) + + req = NewRequest(t, "GET", redirect) + resp = session.MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "newly-added-test-file") +} + +func TestEmptyRepoUploadFile(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user30") + req := NewRequest(t, "GET", "/user30/empty/_new/"+setting.Repository.DefaultBranch) + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body).Find(`input[name="commit_choice"]`) + assert.Empty(t, doc.AttrOr("checked", "_no_")) + + body := &bytes.Buffer{} + mpForm := multipart.NewWriter(body) + _ = mpForm.WriteField("_csrf", GetCSRF(t, session, "/user/settings")) + file, _ := mpForm.CreateFormFile("file", "uploaded-file.txt") + _, _ = io.Copy(file, bytes.NewBufferString("newly-uploaded-test-file")) + _ = mpForm.Close() + + req = NewRequestWithBody(t, "POST", "/user30/empty/upload-file", body) + req.Header.Add("Content-Type", mpForm.FormDataContentType()) + resp = session.MakeRequest(t, req, http.StatusOK) + respMap := map[string]string{} + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &respMap)) + + req = NewRequestWithValues(t, "POST", "/user30/empty/_upload/"+setting.Repository.DefaultBranch, map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings"), + "commit_choice": "direct", + "files": respMap["uuid"], + "tree_path": "", + "commit_mail_id": "-1", + }) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + redirect := test.RedirectURL(resp) + assert.Equal(t, "/user30/empty/src/branch/"+setting.Repository.DefaultBranch+"/", redirect) + + req = NewRequest(t, "GET", redirect) + resp = session.MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "uploaded-file.txt") +} + +func TestEmptyRepoAddFileByAPI(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user30") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user30/empty/contents/new-file.txt", &api.CreateFileOptions{ + FileOptions: api.FileOptions{ + NewBranchName: "new_branch", + Message: "init", + }, + ContentBase64: base64.StdEncoding.EncodeToString([]byte("newly-added-api-file")), + }).AddTokenAuth(token) + + resp := MakeRequest(t, req, http.StatusCreated) + var fileResponse api.FileResponse + DecodeJSON(t, resp, &fileResponse) + expectedHTMLURL := setting.AppURL + "user30/empty/src/branch/new_branch/new-file.txt" + assert.EqualValues(t, expectedHTMLURL, *fileResponse.Content.HTMLURL) + + req = NewRequest(t, "GET", "/user30/empty/src/branch/new_branch/new-file.txt") + resp = session.MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "newly-added-api-file") + + req = NewRequest(t, "GET", "/api/v1/repos/user30/empty"). + AddTokenAuth(token) + resp = session.MakeRequest(t, req, http.StatusOK) + var apiRepo api.Repository + DecodeJSON(t, resp, &apiRepo) + assert.Equal(t, "new_branch", apiRepo.DefaultBranch) +} diff --git a/tests/integration/eventsource_test.go b/tests/integration/eventsource_test.go new file mode 100644 index 0000000..e081df0 --- /dev/null +++ b/tests/integration/eventsource_test.go @@ -0,0 +1,88 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + "time" + + activities_model "code.gitea.io/gitea/models/activities" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/eventsource" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEventSourceManagerRun(t *testing.T) { + defer tests.PrepareTestEnv(t)() + manager := eventsource.GetManager() + + eventChan := manager.Register(2) + defer func() { + manager.Unregister(2, eventChan) + // ensure the eventChan is closed + for { + _, ok := <-eventChan + if !ok { + break + } + } + }() + expectNotificationCountEvent := func(count int64) func() bool { + return func() bool { + select { + case event, ok := <-eventChan: + if !ok { + return false + } + data, ok := event.Data.(activities_model.UserIDCount) + if !ok { + return false + } + return event.Name == "notification-count" && data.Count == count + default: + return false + } + } + } + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + thread5 := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 5}) + require.NoError(t, thread5.LoadAttributes(db.DefaultContext)) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteNotification, auth_model.AccessTokenScopeWriteRepository) + + var apiNL []api.NotificationThread + + // -- mark notifications as read -- + req := NewRequest(t, "GET", "/api/v1/notifications?status-types=unread"). + AddTokenAuth(token) + resp := session.MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &apiNL) + assert.Len(t, apiNL, 2) + + lastReadAt := "2000-01-01T00%3A50%3A01%2B00%3A00" // 946687801 <- only Notification 4 is in this filter ... + req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/notifications?last_read_at=%s", user2.Name, repo1.Name, lastReadAt)). + AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusResetContent) + + req = NewRequest(t, "GET", "/api/v1/notifications?status-types=unread"). + AddTokenAuth(token) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiNL) + assert.Len(t, apiNL, 1) + + assert.Eventually(t, expectNotificationCountEvent(1), 30*time.Second, 1*time.Second) +} diff --git a/tests/integration/explore_code_test.go b/tests/integration/explore_code_test.go new file mode 100644 index 0000000..d84b47c --- /dev/null +++ b/tests/integration/explore_code_test.go @@ -0,0 +1,31 @@ +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" +) + +func TestExploreCodeSearchIndexer(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Indexer.RepoIndexerEnabled, true)() + + req := NewRequest(t, "GET", "/explore/code?q=file&fuzzy=true") + resp := MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body).Find(".explore") + + msg := doc. + Find(".ui.container"). + Find(".ui.message[data-test-tag=grep]") + assert.EqualValues(t, 0, msg.Length()) + + doc.Find(".file-body").Each(func(i int, sel *goquery.Selection) { + assert.Positive(t, sel.Find(".code-inner").Find(".search-highlight").Length(), 0) + }) +} diff --git a/tests/integration/explore_repos_test.go b/tests/integration/explore_repos_test.go new file mode 100644 index 0000000..c0179c5 --- /dev/null +++ b/tests/integration/explore_repos_test.go @@ -0,0 +1,31 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestExploreRepos(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/explore/repos") + MakeRequest(t, req, http.StatusOK) + + t.Run("Persistent parameters", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/explore/repos?topic=1&language=Go") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body).Find("#repo-search-form") + + assert.EqualValues(t, "Go", htmlDoc.Find("input[name='language']").AttrOr("value", "not found")) + assert.EqualValues(t, "true", htmlDoc.Find("input[name='topic']").AttrOr("value", "not found")) + }) +} diff --git a/tests/integration/explore_user_test.go b/tests/integration/explore_user_test.go new file mode 100644 index 0000000..441d89c --- /dev/null +++ b/tests/integration/explore_user_test.go @@ -0,0 +1,44 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestExploreUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + cases := []struct{ sortOrder, expected string }{ + {"", "?sort=newest&q="}, + {"newest", "?sort=newest&q="}, + {"oldest", "?sort=oldest&q="}, + {"alphabetically", "?sort=alphabetically&q="}, + {"reversealphabetically", "?sort=reversealphabetically&q="}, + } + for _, c := range cases { + req := NewRequest(t, "GET", "/explore/users?sort="+c.sortOrder) + resp := MakeRequest(t, req, http.StatusOK) + h := NewHTMLParser(t, resp.Body) + href, _ := h.Find(`.ui.dropdown .menu a.active.item[href^="?sort="]`).Attr("href") + assert.Equal(t, c.expected, href) + } + + // these sort orders shouldn't be supported, to avoid leaking user activity + cases404 := []string{ + "/explore/users?sort=lastlogin", + "/explore/users?sort=reverselastlogin", + "/explore/users?sort=leastupdate", + "/explore/users?sort=reverseleastupdate", + } + for _, c := range cases404 { + req := NewRequest(t, "GET", c).SetHeader("Accept", "text/html") + MakeRequest(t, req, http.StatusNotFound) + } +} diff --git a/tests/integration/fixtures/TestAdminDeleteUser/issue.yml b/tests/integration/fixtures/TestAdminDeleteUser/issue.yml new file mode 100644 index 0000000..02ea88e --- /dev/null +++ b/tests/integration/fixtures/TestAdminDeleteUser/issue.yml @@ -0,0 +1,16 @@ +- + id: 1000 + repo_id: 1000 + index: 2 + poster_id: 1000 + original_author_id: 0 + name: NAME + content: content + milestone_id: 0 + priority: 0 + is_closed: false + is_pull: false + num_comments: 0 + created_unix: 946684830 + updated_unix: 978307200 + is_locked: false diff --git a/tests/integration/fixtures/TestAdminDeleteUser/issue_index.yml b/tests/integration/fixtures/TestAdminDeleteUser/issue_index.yml new file mode 100644 index 0000000..88aae4d --- /dev/null +++ b/tests/integration/fixtures/TestAdminDeleteUser/issue_index.yml @@ -0,0 +1,3 @@ +- + group_id: 1000 + max_index: 2 diff --git a/tests/integration/fixtures/TestAdminDeleteUser/repository.yml b/tests/integration/fixtures/TestAdminDeleteUser/repository.yml new file mode 100644 index 0000000..2c12c7e --- /dev/null +++ b/tests/integration/fixtures/TestAdminDeleteUser/repository.yml @@ -0,0 +1,30 @@ +- + id: 1000 + owner_id: 1001 + owner_name: user1001 + lower_name: repo1000 + name: repo1000 + default_branch: master + num_watches: 0 + num_stars: 0 + num_forks: 0 + num_issues: 1 + num_closed_issues: 0 + num_pulls: 0 + num_closed_pulls: 0 + num_milestones: 0 + num_closed_milestones: 0 + num_projects: 0 + num_closed_projects: 0 + is_private: false + is_empty: false + is_archived: false + is_mirror: false + status: 0 + is_fork: false + fork_id: 0 + is_template: false + template_id: 0 + size: 0 + is_fsck_enabled: true + close_issues_via_commit_in_any_branch: false diff --git a/tests/integration/fixtures/TestAdminDeleteUser/user.yml b/tests/integration/fixtures/TestAdminDeleteUser/user.yml new file mode 100644 index 0000000..9b44a85 --- /dev/null +++ b/tests/integration/fixtures/TestAdminDeleteUser/user.yml @@ -0,0 +1,73 @@ +- + id: 1000 + lower_name: user1000 + name: user1000 + full_name: User Thousand + email: user1000@example.com + keep_email_private: false + email_notifications_preference: enabled + passwd: ZogKvWdyEx:password + passwd_hash_algo: dummy + must_change_password: false + login_source: 0 + login_name: user1000 + type: 0 + salt: ZogKvWdyEx + max_repo_creation: -1 + is_active: true + is_admin: false + is_restricted: false + allow_git_hook: false + allow_import_local: false + allow_create_organization: true + prohibit_login: false + avatar: avatar1000 + avatar_email: user1000@example.com + use_custom_avatar: false + num_followers: 1 + num_following: 1 + num_stars: 0 + num_repos: 0 + num_teams: 0 + num_members: 0 + visibility: 0 + repo_admin_change_team_access: false + theme: "" + keep_activity_private: false + +- + id: 1001 + lower_name: user1001 + name: user1001 + full_name: User 1001 + email: user1001@example.com + keep_email_private: false + email_notifications_preference: enabled + passwd: ZogKvWdyEx:password + passwd_hash_algo: dummy + must_change_password: false + login_source: 0 + login_name: user1001 + type: 0 + salt: ZogKvWdyEx + max_repo_creation: -1 + is_active: true + is_admin: false + is_restricted: false + allow_git_hook: false + allow_import_local: false + allow_create_organization: true + prohibit_login: false + avatar: avatar1001 + avatar_email: user1001@example.com + use_custom_avatar: false + num_followers: 0 + num_following: 0 + num_stars: 0 + num_repos: 1 + num_teams: 0 + num_members: 0 + visibility: 0 + repo_admin_change_team_access: false + theme: "" + keep_activity_private: false diff --git a/tests/integration/fixtures/TestBlockActions/comment.yml b/tests/integration/fixtures/TestBlockActions/comment.yml new file mode 100644 index 0000000..bf5bc34 --- /dev/null +++ b/tests/integration/fixtures/TestBlockActions/comment.yml @@ -0,0 +1,9 @@ + +- + id: 1008 + type: 0 # comment + poster_id: 2 + issue_id: 4 # in repo_id 2 + content: "comment in private pository" + created_unix: 946684811 + updated_unix: 946684811 diff --git a/tests/integration/fixtures/TestBlockActions/issue.yml b/tests/integration/fixtures/TestBlockActions/issue.yml new file mode 100644 index 0000000..f08ef54 --- /dev/null +++ b/tests/integration/fixtures/TestBlockActions/issue.yml @@ -0,0 +1,17 @@ + +- + id: 1004 + repo_id: 2 + index: 1000 + poster_id: 2 + original_author_id: 0 + name: issue1004 + content: content for the 1000 fourth issue + milestone_id: 0 + priority: 0 + is_closed: true + is_pull: false + num_comments: 1 + created_unix: 946684830 + updated_unix: 978307200 + is_locked: false diff --git a/tests/integration/fixtures/TestBlockedNotifications/issue.yml b/tests/integration/fixtures/TestBlockedNotifications/issue.yml new file mode 100644 index 0000000..9524e60 --- /dev/null +++ b/tests/integration/fixtures/TestBlockedNotifications/issue.yml @@ -0,0 +1,16 @@ +- + id: 1000 + repo_id: 4 + index: 1000 + poster_id: 10 + original_author_id: 0 + name: issue for moderation + content: Hello there! + milestone_id: 0 + priority: 0 + is_closed: false + is_pull: false + num_comments: 0 + created_unix: 1705939088 + updated_unix: 1705939088 + is_locked: false diff --git a/tests/integration/fixtures/TestCommitRefComment/comment.yml b/tests/integration/fixtures/TestCommitRefComment/comment.yml new file mode 100644 index 0000000..e2cfa0f --- /dev/null +++ b/tests/integration/fixtures/TestCommitRefComment/comment.yml @@ -0,0 +1,17 @@ +- + id: 1000 + type: 4 # commit ref + poster_id: 2 + issue_id: 2 # in repo_id 2 + content: 4a357436d925b5c974181ff12a994538ddc5a269 + created_unix: 1706469348 + updated_unix: 1706469348 + +- + id: 1001 + type: 4 # commit ref + poster_id: 2 + issue_id: 1 # in repo_id 2 + content: 4a357436d925b5c974181ff12a994538ddc5a269 + created_unix: 1706469348 + updated_unix: 1706469348 diff --git a/tests/integration/fixtures/TestFeed/action.yml b/tests/integration/fixtures/TestFeed/action.yml new file mode 100644 index 0000000..c00e4bc --- /dev/null +++ b/tests/integration/fixtures/TestFeed/action.yml @@ -0,0 +1,9 @@ +- id: 1001 + user_id: 2 + op_type: 10 # close issue + act_user_id: 2 + comment_id: 1001 + repo_id: 1 # public + is_private: false + created_unix: 1680454039 + content: '1|This is a very long text, so lets scream together: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa…' diff --git a/tests/integration/fixtures/TestFeed/comment.yml b/tests/integration/fixtures/TestFeed/comment.yml new file mode 100644 index 0000000..bfa0c8c --- /dev/null +++ b/tests/integration/fixtures/TestFeed/comment.yml @@ -0,0 +1,8 @@ +- + id: 1001 + type: 0 # comment + poster_id: 2 + issue_id: 1 # in repo_id 1 + content: "This is a very long text, so lets scream together: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + created_unix: 1729602027 + updated_unix: 1729602027 diff --git a/tests/integration/fixtures/TestFeed/team.yml b/tests/integration/fixtures/TestFeed/team.yml new file mode 100644 index 0000000..da27ac7 --- /dev/null +++ b/tests/integration/fixtures/TestFeed/team.yml @@ -0,0 +1,21 @@ +- + id: 1001 + org_id: 3 + lower_name: no_code + name: no_code + authorize: 1 # read + num_repos: 1 + num_members: 1 + includes_all_repositories: false + can_create_org_repo: false + +- + id: 1002 + org_id: 3 + lower_name: read_code + name: no_code + authorize: 1 # read + num_repos: 1 + num_members: 1 + includes_all_repositories: false + can_create_org_repo: false diff --git a/tests/integration/fixtures/TestFeed/team_repo.yml b/tests/integration/fixtures/TestFeed/team_repo.yml new file mode 100644 index 0000000..922d1ef --- /dev/null +++ b/tests/integration/fixtures/TestFeed/team_repo.yml @@ -0,0 +1,11 @@ +- + id: 1001 + org_id: 3 + team_id: 1001 + repo_id: 3 + +- + id: 1002 + org_id: 3 + team_id: 1002 + repo_id: 3 diff --git a/tests/integration/fixtures/TestFeed/team_unit.yml b/tests/integration/fixtures/TestFeed/team_unit.yml new file mode 100644 index 0000000..9fcb439 --- /dev/null +++ b/tests/integration/fixtures/TestFeed/team_unit.yml @@ -0,0 +1,83 @@ +- + id: 1001 + team_id: 1001 + type: 1 + access_mode: 0 + +- + id: 1002 + team_id: 1001 + type: 2 + access_mode: 1 + +- + id: 1003 + team_id: 1001 + type: 3 + access_mode: 1 + +- + id: 1004 + team_id: 1001 + type: 4 + access_mode: 1 + +- + id: 1005 + team_id: 1001 + type: 5 + access_mode: 1 + +- + id: 1006 + team_id: 1001 + type: 6 + access_mode: 1 + +- + id: 1007 + team_id: 1001 + type: 7 + access_mode: 1 + +- + id: 1008 + team_id: 1002 + type: 1 + access_mode: 1 + +- + id: 1009 + team_id: 1002 + type: 2 + access_mode: 1 + +- + id: 1010 + team_id: 1002 + type: 3 + access_mode: 1 + +- + id: 1011 + team_id: 1002 + type: 4 + access_mode: 1 + +- + id: 1012 + team_id: 1002 + type: 5 + access_mode: 1 + +- + id: 1013 + team_id: 1002 + type: 6 + access_mode: 1 + +- + id: 1014 + team_id: 1002 + type: 7 + access_mode: 1 diff --git a/tests/integration/fixtures/TestFeed/team_user.yml b/tests/integration/fixtures/TestFeed/team_user.yml new file mode 100644 index 0000000..15fa3eb --- /dev/null +++ b/tests/integration/fixtures/TestFeed/team_user.yml @@ -0,0 +1,11 @@ +- + id: 1001 + org_id: 3 + team_id: 1001 + uid: 8 + +- + id: 1002 + org_id: 3 + team_id: 1002 + uid: 9 diff --git a/tests/integration/fixtures/TestGetContentHistory/issue_content_history.yml b/tests/integration/fixtures/TestGetContentHistory/issue_content_history.yml new file mode 100644 index 0000000..6633b50 --- /dev/null +++ b/tests/integration/fixtures/TestGetContentHistory/issue_content_history.yml @@ -0,0 +1,17 @@ +- + id: 1 + issue_id: 1 + comment_id: 3 + edited_unix: 1687612839 + content_text: Original Text + is_first_created: true + is_deleted: false + +- + id: 2 + issue_id: 1 + comment_id: 3 + edited_unix: 1687612840 + content_text: "meh..." # This has to be consistent with comment.yml + is_first_created: false + is_deleted: false diff --git a/tests/integration/fixtures/TestXSSReviewDismissed/comment.yml b/tests/integration/fixtures/TestXSSReviewDismissed/comment.yml new file mode 100644 index 0000000..50162a4 --- /dev/null +++ b/tests/integration/fixtures/TestXSSReviewDismissed/comment.yml @@ -0,0 +1,9 @@ +- + id: 1000 + type: 32 # dismiss review + poster_id: 2 + issue_id: 2 # in repo_id 1 + content: "XSS time!" + review_id: 1000 + created_unix: 1700000000 + updated_unix: 1700000000 diff --git a/tests/integration/fixtures/TestXSSReviewDismissed/review.yml b/tests/integration/fixtures/TestXSSReviewDismissed/review.yml new file mode 100644 index 0000000..56bc08d --- /dev/null +++ b/tests/integration/fixtures/TestXSSReviewDismissed/review.yml @@ -0,0 +1,8 @@ +- + id: 1000 + type: 1 + issue_id: 2 + original_author: "Otto <script class='evil'>alert('Oh no!')</script>" + content: "XSS time!" + updated_unix: 1700000000 + created_unix: 1700000000 diff --git a/tests/integration/forgejo_confirmation_repo_test.go b/tests/integration/forgejo_confirmation_repo_test.go new file mode 100644 index 0000000..598baa4 --- /dev/null +++ b/tests/integration/forgejo_confirmation_repo_test.go @@ -0,0 +1,184 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/http/httptest" + "testing" + + "code.gitea.io/gitea/modules/translation" + gitea_context "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestDangerZoneConfirmation(t *testing.T) { + defer tests.PrepareTestEnv(t)() + mustInvalidRepoName := func(resp *httptest.ResponseRecorder) { + t.Helper() + + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, + htmlDoc.doc.Find(".ui.negative.message").Text(), + translation.NewLocale("en-US").Tr("form.enterred_invalid_repo_name"), + ) + } + + t.Run("Transfer ownership", func(t *testing.T) { + session := loginUser(t, "user2") + + t.Run("Fail", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user2/repo1/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1/settings"), + "action": "transfer", + "repo_name": "repo1", + "new_owner_name": "user1", + }) + resp := session.MakeRequest(t, req, http.StatusOK) + mustInvalidRepoName(resp) + }) + t.Run("Pass", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user2/repo1/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1/settings"), + "action": "transfer", + "repo_name": "user2/repo1", + "new_owner_name": "user1", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "success%3DThis%2Brepository%2Bhas%2Bbeen%2Bmarked%2Bfor%2Btransfer%2Band%2Bawaits%2Bconfirmation%2Bfrom%2B%2522User%2BOne%2522", flashCookie.Value) + }) + }) + + t.Run("Convert fork", func(t *testing.T) { + session := loginUser(t, "user20") + + t.Run("Fail", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user20/big_test_public_fork_7/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user20/big_test_public_fork_7/settings"), + "action": "convert_fork", + "repo_name": "big_test_public_fork_7", + }) + resp := session.MakeRequest(t, req, http.StatusOK) + mustInvalidRepoName(resp) + }) + t.Run("Pass", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user20/big_test_public_fork_7/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user20/big_test_public_fork_7/settings"), + "action": "convert_fork", + "repo_name": "user20/big_test_public_fork_7", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "success%3DThe%2Bfork%2Bhas%2Bbeen%2Bconverted%2Binto%2Ba%2Bregular%2Brepository.", flashCookie.Value) + }) + }) + + t.Run("Rename wiki branch", func(t *testing.T) { + session := loginUser(t, "user2") + + // NOTE: No need to rename the wiki branch here to make the form appear. + // We can submit it anyway, even if it doesn't appear on the web. + + t.Run("Fail", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user2/repo1/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1/settings"), + "action": "rename-wiki-branch", + "repo_name": "repo1", + }) + resp := session.MakeRequest(t, req, http.StatusOK) + mustInvalidRepoName(resp) + }) + t.Run("Pass", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user2/repo1/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1/settings"), + "action": "rename-wiki-branch", + "repo_name": "user2/repo1", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "success%3DThe%2Brepository%2Bwiki%2527s%2Bbranch%2Bname%2Bhas%2Bbeen%2Bsuccessfully%2Bnormalized.", flashCookie.Value) + }) + }) + + t.Run("Delete wiki", func(t *testing.T) { + session := loginUser(t, "user2") + + t.Run("Fail", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user2/repo1/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1/settings"), + "action": "delete-wiki", + "repo_name": "repo1", + }) + resp := session.MakeRequest(t, req, http.StatusOK) + mustInvalidRepoName(resp) + }) + t.Run("Pass", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user2/repo1/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1/settings"), + "action": "delete-wiki", + "repo_name": "user2/repo1", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "success%3DThe%2Brepository%2Bwiki%2Bdata%2Bhas%2Bbeen%2Bdeleted.", flashCookie.Value) + }) + }) + + t.Run("Delete", func(t *testing.T) { + session := loginUser(t, "user2") + + t.Run("Fail", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user2/repo1/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1/settings"), + "action": "delete", + "repo_name": "repo1", + }) + resp := session.MakeRequest(t, req, http.StatusOK) + mustInvalidRepoName(resp) + }) + t.Run("Pass", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user2/repo1/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1/settings"), + "action": "delete", + "repo_name": "user2/repo1", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "success%3DThe%2Brepository%2Bhas%2Bbeen%2Bdeleted.", flashCookie.Value) + }) + }) +} diff --git a/tests/integration/forgejo_git_test.go b/tests/integration/forgejo_git_test.go new file mode 100644 index 0000000..ebad074 --- /dev/null +++ b/tests/integration/forgejo_git_test.go @@ -0,0 +1,137 @@ +// Copyright Earl Warren <contact@earl-warren.org> +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "os" + "path" + "testing" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/require" +) + +func TestActionsUserGit(t *testing.T) { + onGiteaRun(t, testActionsUserGit) +} + +func NewActionsUserTestContext(t *testing.T, username, reponame string) APITestContext { + t.Helper() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: reponame}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username}) + + task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 47}) + task.RepoID = repo.ID + task.OwnerID = repoOwner.ID + task.GenerateToken() + + actions_model.UpdateTask(db.DefaultContext, task) + return APITestContext{ + Session: emptyTestSession(t), + Token: task.Token, + Username: username, + Reponame: reponame, + } +} + +func testActionsUserGit(t *testing.T, u *url.URL) { + username := "user2" + reponame := "repo1" + httpContext := NewAPITestContext(t, username, reponame, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + for _, testCase := range []struct { + name string + head string + ctx APITestContext + }{ + { + name: "UserTypeIndividual", + head: "individualhead", + ctx: httpContext, + }, + { + name: "ActionsUser", + head: "actionsuserhead", + ctx: NewActionsUserTestContext(t, username, reponame), + }, + } { + t.Run("CreatePR "+testCase.name, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + dstPath := t.TempDir() + u.Path = httpContext.GitPath() + u.User = url.UserPassword(httpContext.Username, userPassword) + t.Run("Clone", doGitClone(dstPath, u)) + t.Run("PopulateBranch", doActionsUserPopulateBranch(dstPath, &httpContext, "master", testCase.head)) + t.Run("CreatePR", doActionsUserPR(httpContext, testCase.ctx, "master", testCase.head)) + }) + } +} + +func doActionsUserPopulateBranch(dstPath string, ctx *APITestContext, baseBranch, headBranch string) func(t *testing.T) { + return func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("CreateHeadBranch", doGitCreateBranch(dstPath, headBranch)) + + t.Run("AddCommit", func(t *testing.T) { + err := os.WriteFile(path.Join(dstPath, "test_file"), []byte("## test content"), 0o666) + require.NoError(t, err) + + err = git.AddChanges(dstPath, true) + require.NoError(t, err) + + err = git.CommitChanges(dstPath, git.CommitChangesOptions{ + Committer: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Author: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Message: "Testing commit 1", + }) + require.NoError(t, err) + }) + + t.Run("Push", func(t *testing.T) { + err := git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments("HEAD:refs/heads/" + headBranch).Run(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + }) + } +} + +func doActionsUserPR(ctx, doerCtx APITestContext, baseBranch, headBranch string) func(t *testing.T) { + return func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + var pr api.PullRequest + var err error + + // Create a test pullrequest + t.Run("CreatePullRequest", func(t *testing.T) { + pr, err = doAPICreatePullRequest(doerCtx, ctx.Username, ctx.Reponame, baseBranch, headBranch)(t) + require.NoError(t, err) + }) + doerCtx.ExpectedCode = http.StatusCreated + t.Run("AutoMergePR", doAPIAutoMergePullRequest(doerCtx, ctx.Username, ctx.Reponame, pr.Index)) + // Ensure the PR page works + t.Run("EnsureCanSeePull", doEnsureCanSeePull(ctx, pr, true)) + } +} diff --git a/tests/integration/git_clone_wiki_test.go b/tests/integration/git_clone_wiki_test.go new file mode 100644 index 0000000..ec99374 --- /dev/null +++ b/tests/integration/git_clone_wiki_test.go @@ -0,0 +1,52 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "net/url" + "os" + "path/filepath" + "testing" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func assertFileExist(t *testing.T, p string) { + exist, err := util.IsExist(p) + require.NoError(t, err) + assert.True(t, exist) +} + +func assertFileEqual(t *testing.T, p string, content []byte) { + bs, err := os.ReadFile(p) + require.NoError(t, err) + assert.EqualValues(t, content, bs) +} + +func TestRepoCloneWiki(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + defer tests.PrepareTestEnv(t)() + + dstPath := t.TempDir() + + r := fmt.Sprintf("%suser2/repo1.wiki.git", u.String()) + u, _ = url.Parse(r) + u.User = url.UserPassword("user2", userPassword) + t.Run("Clone", func(t *testing.T) { + require.NoError(t, git.CloneWithArgs(context.Background(), git.AllowLFSFiltersArgs(), u.String(), dstPath, git.CloneRepoOptions{})) + assertFileEqual(t, filepath.Join(dstPath, "Home.md"), []byte("# Home page\n\nThis is the home page!\n")) + assertFileExist(t, filepath.Join(dstPath, "Page-With-Image.md")) + assertFileExist(t, filepath.Join(dstPath, "Page-With-Spaced-Name.md")) + assertFileExist(t, filepath.Join(dstPath, "images")) + assertFileExist(t, filepath.Join(dstPath, "jpeg.jpg")) + }) + }) +} diff --git a/tests/integration/git_helper_for_declarative_test.go b/tests/integration/git_helper_for_declarative_test.go new file mode 100644 index 0000000..490d4ca --- /dev/null +++ b/tests/integration/git_helper_for_declarative_test.go @@ -0,0 +1,211 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strconv" + "testing" + "time" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/ssh" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func withKeyFile(t *testing.T, keyname string, callback func(string)) { + tmpDir := t.TempDir() + + err := os.Chmod(tmpDir, 0o700) + require.NoError(t, err) + + keyFile := filepath.Join(tmpDir, keyname) + err = ssh.GenKeyPair(keyFile) + require.NoError(t, err) + + err = os.WriteFile(path.Join(tmpDir, "ssh"), []byte("#!/bin/bash\n"+ + "ssh -o \"UserKnownHostsFile=/dev/null\" -o \"StrictHostKeyChecking=no\" -o \"IdentitiesOnly=yes\" -i \""+keyFile+"\" \"$@\""), 0o700) + require.NoError(t, err) + + // Setup ssh wrapper + t.Setenv("GIT_SSH", path.Join(tmpDir, "ssh")) + t.Setenv("GIT_SSH_COMMAND", + "ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -i \""+keyFile+"\"") + t.Setenv("GIT_SSH_VARIANT", "ssh") + + callback(keyFile) +} + +func createSSHUrl(gitPath string, u *url.URL) *url.URL { + u2 := *u + u2.Scheme = "ssh" + u2.User = url.User("git") + u2.Host = net.JoinHostPort(setting.SSH.ListenHost, strconv.Itoa(setting.SSH.ListenPort)) + u2.Path = gitPath + return &u2 +} + +func onGiteaRun[T testing.TB](t T, callback func(T, *url.URL)) { + defer tests.PrepareTestEnv(t, 1)() + s := http.Server{ + Handler: testWebRoutes, + } + + u, err := url.Parse(setting.AppURL) + require.NoError(t, err) + listener, err := net.Listen("tcp", u.Host) + i := 0 + for err != nil && i <= 10 { + time.Sleep(100 * time.Millisecond) + listener, err = net.Listen("tcp", u.Host) + i++ + } + require.NoError(t, err) + u.Host = listener.Addr().String() + + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + s.Shutdown(ctx) + cancel() + }() + + go s.Serve(listener) + // Started by config go ssh.Listen(setting.SSH.ListenHost, setting.SSH.ListenPort, setting.SSH.ServerCiphers, setting.SSH.ServerKeyExchanges, setting.SSH.ServerMACs) + + callback(t, u) +} + +func doGitClone(dstLocalPath string, u *url.URL) func(*testing.T) { + return func(t *testing.T) { + t.Helper() + require.NoError(t, git.CloneWithArgs(context.Background(), git.AllowLFSFiltersArgs(), u.String(), dstLocalPath, git.CloneRepoOptions{})) + exist, err := util.IsExist(filepath.Join(dstLocalPath, "README.md")) + require.NoError(t, err) + assert.True(t, exist) + } +} + +func doPartialGitClone(dstLocalPath string, u *url.URL) func(*testing.T) { + return func(t *testing.T) { + t.Helper() + require.NoError(t, git.CloneWithArgs(context.Background(), git.AllowLFSFiltersArgs(), u.String(), dstLocalPath, git.CloneRepoOptions{ + Filter: "blob:none", + })) + exist, err := util.IsExist(filepath.Join(dstLocalPath, "README.md")) + require.NoError(t, err) + assert.True(t, exist) + } +} + +func doGitCloneFail(u *url.URL) func(*testing.T) { + return func(t *testing.T) { + t.Helper() + tmpDir := t.TempDir() + require.Error(t, git.Clone(git.DefaultContext, u.String(), tmpDir, git.CloneRepoOptions{})) + exist, err := util.IsExist(filepath.Join(tmpDir, "README.md")) + require.NoError(t, err) + assert.False(t, exist) + } +} + +func doGitInitTestRepository(dstPath string, objectFormat git.ObjectFormat) func(*testing.T) { + return func(t *testing.T) { + t.Helper() + // Init repository in dstPath + require.NoError(t, git.InitRepository(git.DefaultContext, dstPath, false, objectFormat.Name())) + // forcibly set default branch to master + _, _, err := git.NewCommand(git.DefaultContext, "symbolic-ref", "HEAD", git.BranchPrefix+"master").RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dstPath, "README.md"), []byte(fmt.Sprintf("# Testing Repository\n\nOriginally created in: %s", dstPath)), 0o644)) + require.NoError(t, git.AddChanges(dstPath, true)) + signature := git.Signature{ + Email: "test@example.com", + Name: "test", + When: time.Now(), + } + require.NoError(t, git.CommitChanges(dstPath, git.CommitChangesOptions{ + Committer: &signature, + Author: &signature, + Message: "Initial Commit", + })) + } +} + +func doGitAddRemote(dstPath, remoteName string, u *url.URL) func(*testing.T) { + return func(t *testing.T) { + t.Helper() + _, _, err := git.NewCommand(git.DefaultContext, "remote", "add").AddDynamicArguments(remoteName, u.String()).RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + } +} + +func doGitPushTestRepository(dstPath string, args ...string) func(*testing.T) { + return func(t *testing.T) { + t.Helper() + _, _, err := git.NewCommand(git.DefaultContext, "push", "-u").AddArguments(git.ToTrustedCmdArgs(args)...).RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + } +} + +func doGitPushTestRepositoryFail(dstPath string, args ...string) func(*testing.T) { + return func(t *testing.T) { + t.Helper() + _, _, err := git.NewCommand(git.DefaultContext, "push").AddArguments(git.ToTrustedCmdArgs(args)...).RunStdString(&git.RunOpts{Dir: dstPath}) + require.Error(t, err) + } +} + +func doGitAddSomeCommits(dstPath, branch string) func(*testing.T) { + return func(t *testing.T) { + doGitCheckoutBranch(dstPath, branch)(t) + + require.NoError(t, os.WriteFile(filepath.Join(dstPath, fmt.Sprintf("file-%s.txt", branch)), []byte(fmt.Sprintf("file %s", branch)), 0o644)) + require.NoError(t, git.AddChanges(dstPath, true)) + signature := git.Signature{ + Email: "test@test.test", + Name: "test", + } + require.NoError(t, git.CommitChanges(dstPath, git.CommitChangesOptions{ + Committer: &signature, + Author: &signature, + Message: fmt.Sprintf("update %s", branch), + })) + } +} + +func doGitCreateBranch(dstPath, branch string) func(*testing.T) { + return func(t *testing.T) { + t.Helper() + _, _, err := git.NewCommand(git.DefaultContext, "checkout", "-b").AddDynamicArguments(branch).RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + } +} + +func doGitCheckoutBranch(dstPath string, args ...string) func(*testing.T) { + return func(t *testing.T) { + t.Helper() + _, _, err := git.NewCommandContextNoGlobals(git.DefaultContext, git.AllowLFSFiltersArgs()...).AddArguments("checkout").AddArguments(git.ToTrustedCmdArgs(args)...).RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + } +} + +func doGitPull(dstPath string, args ...string) func(*testing.T) { + return func(t *testing.T) { + t.Helper() + _, _, err := git.NewCommandContextNoGlobals(git.DefaultContext, git.AllowLFSFiltersArgs()...).AddArguments("pull").AddArguments(git.ToTrustedCmdArgs(args)...).RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + } +} diff --git a/tests/integration/git_push_test.go b/tests/integration/git_push_test.go new file mode 100644 index 0000000..c9c33dc --- /dev/null +++ b/tests/integration/git_push_test.go @@ -0,0 +1,288 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/url" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/test" + repo_service "code.gitea.io/gitea/services/repository" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func forEachObjectFormat(t *testing.T, f func(t *testing.T, objectFormat git.ObjectFormat)) { + for _, objectFormat := range []git.ObjectFormat{git.Sha256ObjectFormat, git.Sha1ObjectFormat} { + t.Run(objectFormat.Name(), func(t *testing.T) { + f(t, objectFormat) + }) + } +} + +func TestGitPush(t *testing.T) { + onGiteaRun(t, testGitPush) +} + +func testGitPush(t *testing.T, u *url.URL) { + forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) { + t.Run("Push branches at once", func(t *testing.T) { + runTestGitPush(t, u, objectFormat, func(t *testing.T, gitPath string) (pushed, deleted []string) { + for i := 0; i < 100; i++ { + branchName := fmt.Sprintf("branch-%d", i) + pushed = append(pushed, branchName) + doGitCreateBranch(gitPath, branchName)(t) + } + pushed = append(pushed, "master") + doGitPushTestRepository(gitPath, "origin", "--all")(t) + return pushed, deleted + }) + }) + + t.Run("Push branches exists", func(t *testing.T) { + runTestGitPush(t, u, objectFormat, func(t *testing.T, gitPath string) (pushed, deleted []string) { + for i := 0; i < 10; i++ { + branchName := fmt.Sprintf("branch-%d", i) + if i < 5 { + pushed = append(pushed, branchName) + } + doGitCreateBranch(gitPath, branchName)(t) + } + // only push master and the first 5 branches + pushed = append(pushed, "master") + args := append([]string{"origin"}, pushed...) + doGitPushTestRepository(gitPath, args...)(t) + + pushed = pushed[:0] + // do some changes for the first 5 branches created above + for i := 0; i < 5; i++ { + branchName := fmt.Sprintf("branch-%d", i) + pushed = append(pushed, branchName) + + doGitAddSomeCommits(gitPath, branchName)(t) + } + + for i := 5; i < 10; i++ { + pushed = append(pushed, fmt.Sprintf("branch-%d", i)) + } + pushed = append(pushed, "master") + + // push all, so that master are not changed + doGitPushTestRepository(gitPath, "origin", "--all")(t) + + return pushed, deleted + }) + }) + + t.Run("Push branches one by one", func(t *testing.T) { + runTestGitPush(t, u, objectFormat, func(t *testing.T, gitPath string) (pushed, deleted []string) { + for i := 0; i < 100; i++ { + branchName := fmt.Sprintf("branch-%d", i) + doGitCreateBranch(gitPath, branchName)(t) + doGitPushTestRepository(gitPath, "origin", branchName)(t) + pushed = append(pushed, branchName) + } + return pushed, deleted + }) + }) + + t.Run("Delete branches", func(t *testing.T) { + runTestGitPush(t, u, objectFormat, func(t *testing.T, gitPath string) (pushed, deleted []string) { + doGitPushTestRepository(gitPath, "origin", "master")(t) // make sure master is the default branch instead of a branch we are going to delete + pushed = append(pushed, "master") + + for i := 0; i < 100; i++ { + branchName := fmt.Sprintf("branch-%d", i) + pushed = append(pushed, branchName) + doGitCreateBranch(gitPath, branchName)(t) + } + doGitPushTestRepository(gitPath, "origin", "--all")(t) + + for i := 0; i < 10; i++ { + branchName := fmt.Sprintf("branch-%d", i) + doGitPushTestRepository(gitPath, "origin", "--delete", branchName)(t) + deleted = append(deleted, branchName) + } + return pushed, deleted + }) + }) + + t.Run("Push to deleted branch", func(t *testing.T) { + runTestGitPush(t, u, objectFormat, func(t *testing.T, gitPath string) (pushed, deleted []string) { + doGitPushTestRepository(gitPath, "origin", "master")(t) // make sure master is the default branch instead of a branch we are going to delete + pushed = append(pushed, "master") + + doGitCreateBranch(gitPath, "branch-1")(t) + doGitPushTestRepository(gitPath, "origin", "branch-1")(t) + pushed = append(pushed, "branch-1") + + // delete and restore + doGitPushTestRepository(gitPath, "origin", "--delete", "branch-1")(t) + doGitPushTestRepository(gitPath, "origin", "branch-1")(t) + + return pushed, deleted + }) + }) + }) +} + +func runTestGitPush(t *testing.T, u *url.URL, objectFormat git.ObjectFormat, gitOperation func(t *testing.T, gitPath string) (pushed, deleted []string)) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo, err := repo_service.CreateRepository(db.DefaultContext, user, user, repo_service.CreateRepoOptions{ + Name: "repo-to-push", + Description: "test git push", + AutoInit: false, + DefaultBranch: "main", + IsPrivate: false, + ObjectFormatName: objectFormat.Name(), + }) + require.NoError(t, err) + require.NotEmpty(t, repo) + + gitPath := t.TempDir() + + doGitInitTestRepository(gitPath, objectFormat)(t) + + oldPath := u.Path + oldUser := u.User + defer func() { + u.Path = oldPath + u.User = oldUser + }() + u.Path = repo.FullName() + ".git" + u.User = url.UserPassword(user.LowerName, userPassword) + + doGitAddRemote(gitPath, "origin", u)(t) + + gitRepo, err := git.OpenRepository(git.DefaultContext, gitPath) + require.NoError(t, err) + defer gitRepo.Close() + + pushedBranches, deletedBranches := gitOperation(t, gitPath) + + dbBranches := make([]*git_model.Branch, 0) + require.NoError(t, db.GetEngine(db.DefaultContext).Where("repo_id=?", repo.ID).Find(&dbBranches)) + assert.Equalf(t, len(pushedBranches), len(dbBranches), "mismatched number of branches in db") + dbBranchesMap := make(map[string]*git_model.Branch, len(dbBranches)) + for _, branch := range dbBranches { + dbBranchesMap[branch.Name] = branch + } + + deletedBranchesMap := make(map[string]bool, len(deletedBranches)) + for _, branchName := range deletedBranches { + deletedBranchesMap[branchName] = true + } + + for _, branchName := range pushedBranches { + branch, ok := dbBranchesMap[branchName] + deleted := deletedBranchesMap[branchName] + assert.True(t, ok, "branch %s not found in database", branchName) + assert.Equal(t, deleted, branch.IsDeleted, "IsDeleted of %s is %v, but it's expected to be %v", branchName, branch.IsDeleted, deleted) + commitID, err := gitRepo.GetBranchCommitID(branchName) + require.NoError(t, err) + assert.Equal(t, commitID, branch.CommitID) + } + + require.NoError(t, repo_service.DeleteRepositoryDirectly(db.DefaultContext, user, repo.ID)) +} + +func TestOptionsGitPush(t *testing.T) { + onGiteaRun(t, testOptionsGitPush) +} + +func testOptionsGitPush(t *testing.T, u *url.URL) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) { + repo, err := repo_service.CreateRepository(db.DefaultContext, user, user, repo_service.CreateRepoOptions{ + Name: "repo-to-push", + Description: "test git push", + AutoInit: false, + DefaultBranch: "main", + IsPrivate: false, + ObjectFormatName: objectFormat.Name(), + }) + require.NoError(t, err) + require.NotEmpty(t, repo) + + gitPath := t.TempDir() + + doGitInitTestRepository(gitPath, objectFormat)(t) + + u.Path = repo.FullName() + ".git" + u.User = url.UserPassword(user.LowerName, userPassword) + doGitAddRemote(gitPath, "origin", u)(t) + + t.Run("Unknown push options are silently ignored", func(t *testing.T) { + branchName := "branch0" + doGitCreateBranch(gitPath, branchName)(t) + doGitPushTestRepository(gitPath, "origin", branchName, "-o", "uknownoption=randomvalue", "-o", "repo.private=true")(t) + repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, user.Name, "repo-to-push") + require.NoError(t, err) + require.True(t, repo.IsPrivate) + require.False(t, repo.IsTemplate) + }) + + t.Run("Owner sets private & template to true via push options", func(t *testing.T) { + branchName := "branch1" + doGitCreateBranch(gitPath, branchName)(t) + doGitPushTestRepository(gitPath, "origin", branchName, "-o", "repo.private=true", "-o", "repo.template=true")(t) + repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, user.Name, "repo-to-push") + require.NoError(t, err) + require.True(t, repo.IsPrivate) + require.True(t, repo.IsTemplate) + }) + + t.Run("Owner sets private & template to false via push options", func(t *testing.T) { + branchName := "branch2" + doGitCreateBranch(gitPath, branchName)(t) + doGitPushTestRepository(gitPath, "origin", branchName, "-o", "repo.private=false", "-o", "repo.template=false")(t) + repo, err = repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, user.Name, "repo-to-push") + require.NoError(t, err) + require.False(t, repo.IsPrivate) + require.False(t, repo.IsTemplate) + }) + + // create a collaborator with write access + collaborator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + u.User = url.UserPassword(collaborator.LowerName, userPassword) + doGitAddRemote(gitPath, "collaborator", u)(t) + repo_module.AddCollaborator(db.DefaultContext, repo, collaborator) + + t.Run("Collaborator with write access is allowed to push", func(t *testing.T) { + branchName := "branch3" + doGitCreateBranch(gitPath, branchName)(t) + doGitPushTestRepository(gitPath, "collaborator", branchName)(t) + }) + + t.Run("Collaborator with write access fails to change private & template via push options", func(t *testing.T) { + logChecker, cleanup := test.NewLogChecker(log.DEFAULT, log.TRACE) + logChecker.Filter("permission denied for changing repo settings").StopMark("Git push options validation") + defer cleanup() + branchName := "branch4" + doGitCreateBranch(gitPath, branchName)(t) + doGitPushTestRepositoryFail(gitPath, "collaborator", branchName, "-o", "repo.private=true", "-o", "repo.template=true")(t) + repo, err = repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, user.Name, "repo-to-push") + require.NoError(t, err) + require.False(t, repo.IsPrivate) + require.False(t, repo.IsTemplate) + logFiltered, logStopped := logChecker.Check(5 * time.Second) + assert.True(t, logStopped) + assert.True(t, logFiltered[0]) + }) + + require.NoError(t, repo_service.DeleteRepositoryDirectly(db.DefaultContext, user, repo.ID)) + }) +} diff --git a/tests/integration/git_smart_http_test.go b/tests/integration/git_smart_http_test.go new file mode 100644 index 0000000..2b904ed --- /dev/null +++ b/tests/integration/git_smart_http_test.go @@ -0,0 +1,69 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "io" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGitSmartHTTP(t *testing.T) { + onGiteaRun(t, testGitSmartHTTP) +} + +func testGitSmartHTTP(t *testing.T, u *url.URL) { + kases := []struct { + p string + code int + }{ + { + p: "user2/repo1/info/refs", + code: http.StatusOK, + }, + { + p: "user2/repo1/HEAD", + code: http.StatusOK, + }, + { + p: "user2/repo1/objects/info/alternates", + code: http.StatusNotFound, + }, + { + p: "user2/repo1/objects/info/http-alternates", + code: http.StatusNotFound, + }, + { + p: "user2/repo1/../../custom/conf/app.ini", + code: http.StatusNotFound, + }, + { + p: "user2/repo1/objects/info/../../../../custom/conf/app.ini", + code: http.StatusNotFound, + }, + { + p: `user2/repo1/objects/info/..\..\..\..\custom\conf\app.ini`, + code: http.StatusBadRequest, + }, + } + + for _, kase := range kases { + t.Run(kase.p, func(t *testing.T) { + p := u.String() + kase.p + req, err := http.NewRequest("GET", p, nil) + require.NoError(t, err) + req.SetBasicAuth("user2", userPassword) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.EqualValues(t, kase.code, resp.StatusCode) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + }) + } +} diff --git a/tests/integration/git_test.go b/tests/integration/git_test.go new file mode 100644 index 0000000..5e03d28 --- /dev/null +++ b/tests/integration/git_test.go @@ -0,0 +1,1146 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "fmt" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strconv" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + gitea_context "code.gitea.io/gitea/services/context" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + littleSize = 1024 // 1ko + bigSize = 128 * 1024 * 1024 // 128Mo +) + +func TestGit(t *testing.T) { + onGiteaRun(t, testGit) +} + +func testGit(t *testing.T, u *url.URL) { + username := "user2" + baseAPITestContext := NewAPITestContext(t, username, "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + u.Path = baseAPITestContext.GitPath() + + forkedUserCtx := NewAPITestContext(t, "user4", "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + t.Run("HTTP", func(t *testing.T) { + ensureAnonymousClone(t, u) + forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) { + defer tests.PrintCurrentTest(t)() + httpContext := baseAPITestContext + httpContext.Reponame = "repo-tmp-17-" + objectFormat.Name() + forkedUserCtx.Reponame = httpContext.Reponame + + dstPath := t.TempDir() + + t.Run("CreateRepoInDifferentUser", doAPICreateRepository(forkedUserCtx, false, objectFormat)) + t.Run("AddUserAsCollaborator", doAPIAddCollaborator(forkedUserCtx, httpContext.Username, perm.AccessModeRead)) + + t.Run("ForkFromDifferentUser", doAPIForkRepository(httpContext, forkedUserCtx.Username)) + + u.Path = httpContext.GitPath() + u.User = url.UserPassword(username, userPassword) + + t.Run("Clone", doGitClone(dstPath, u)) + + dstPath2 := t.TempDir() + + t.Run("Partial Clone", doPartialGitClone(dstPath2, u)) + + little, big := standardCommitAndPushTest(t, dstPath) + littleLFS, bigLFS := lfsCommitAndPushTest(t, dstPath) + rawTest(t, &httpContext, little, big, littleLFS, bigLFS) + mediaTest(t, &httpContext, little, big, littleLFS, bigLFS) + + t.Run("CreateAgitFlowPull", doCreateAgitFlowPull(dstPath, &httpContext, "test/head")) + t.Run("InternalReferences", doInternalReferences(&httpContext, dstPath)) + t.Run("BranchProtect", doBranchProtect(&httpContext, dstPath)) + t.Run("AutoMerge", doAutoPRMerge(&httpContext, dstPath)) + t.Run("CreatePRAndSetManuallyMerged", doCreatePRAndSetManuallyMerged(httpContext, httpContext, dstPath, "master", "test-manually-merge")) + t.Run("MergeFork", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + t.Run("CreatePRAndMerge", doMergeFork(httpContext, forkedUserCtx, "master", httpContext.Username+":master")) + rawTest(t, &forkedUserCtx, little, big, littleLFS, bigLFS) + mediaTest(t, &forkedUserCtx, little, big, littleLFS, bigLFS) + }) + + t.Run("PushCreate", doPushCreate(httpContext, u, objectFormat)) + }) + }) + t.Run("SSH", func(t *testing.T) { + forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) { + defer tests.PrintCurrentTest(t)() + sshContext := baseAPITestContext + sshContext.Reponame = "repo-tmp-18-" + objectFormat.Name() + keyname := "my-testing-key" + forkedUserCtx.Reponame = sshContext.Reponame + t.Run("CreateRepoInDifferentUser", doAPICreateRepository(forkedUserCtx, false, objectFormat)) + t.Run("AddUserAsCollaborator", doAPIAddCollaborator(forkedUserCtx, sshContext.Username, perm.AccessModeRead)) + t.Run("ForkFromDifferentUser", doAPIForkRepository(sshContext, forkedUserCtx.Username)) + + // Setup key the user ssh key + withKeyFile(t, keyname, func(keyFile string) { + t.Run("CreateUserKey", doAPICreateUserKey(sshContext, "test-key-"+objectFormat.Name(), keyFile)) + + // Setup remote link + // TODO: get url from api + sshURL := createSSHUrl(sshContext.GitPath(), u) + + // Setup clone folder + dstPath := t.TempDir() + + t.Run("Clone", doGitClone(dstPath, sshURL)) + + little, big := standardCommitAndPushTest(t, dstPath) + littleLFS, bigLFS := lfsCommitAndPushTest(t, dstPath) + rawTest(t, &sshContext, little, big, littleLFS, bigLFS) + mediaTest(t, &sshContext, little, big, littleLFS, bigLFS) + + t.Run("CreateAgitFlowPull", doCreateAgitFlowPull(dstPath, &sshContext, "test/head2")) + t.Run("InternalReferences", doInternalReferences(&sshContext, dstPath)) + t.Run("BranchProtect", doBranchProtect(&sshContext, dstPath)) + t.Run("MergeFork", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + t.Run("CreatePRAndMerge", doMergeFork(sshContext, forkedUserCtx, "master", sshContext.Username+":master")) + rawTest(t, &forkedUserCtx, little, big, littleLFS, bigLFS) + mediaTest(t, &forkedUserCtx, little, big, littleLFS, bigLFS) + }) + + t.Run("PushCreate", doPushCreate(sshContext, sshURL, objectFormat)) + }) + }) + }) +} + +func ensureAnonymousClone(t *testing.T, u *url.URL) { + dstLocalPath := t.TempDir() + t.Run("CloneAnonymous", doGitClone(dstLocalPath, u)) +} + +func standardCommitAndPushTest(t *testing.T, dstPath string) (little, big string) { + t.Run("Standard", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + little, big = commitAndPushTest(t, dstPath, "data-file-") + }) + return little, big +} + +func lfsCommitAndPushTest(t *testing.T, dstPath string) (littleLFS, bigLFS string) { + t.Run("LFS", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + prefix := "lfs-data-file-" + err := git.NewCommand(git.DefaultContext, "lfs").AddArguments("install").Run(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + _, _, err = git.NewCommand(git.DefaultContext, "lfs").AddArguments("track").AddDynamicArguments(prefix + "*").RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + err = git.AddChanges(dstPath, false, ".gitattributes") + require.NoError(t, err) + + err = git.CommitChangesWithArgs(dstPath, git.AllowLFSFiltersArgs(), git.CommitChangesOptions{ + Committer: &git.Signature{ + Email: "user2@example.com", + Name: "User Two", + When: time.Now(), + }, + Author: &git.Signature{ + Email: "user2@example.com", + Name: "User Two", + When: time.Now(), + }, + Message: fmt.Sprintf("Testing commit @ %v", time.Now()), + }) + require.NoError(t, err) + + littleLFS, bigLFS = commitAndPushTest(t, dstPath, prefix) + + t.Run("Locks", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + lockTest(t, dstPath) + }) + }) + return littleLFS, bigLFS +} + +func commitAndPushTest(t *testing.T, dstPath, prefix string) (little, big string) { + t.Run("PushCommit", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + t.Run("Little", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + little = doCommitAndPush(t, littleSize, dstPath, prefix) + }) + t.Run("Big", func(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test in short mode.") + return + } + defer tests.PrintCurrentTest(t)() + big = doCommitAndPush(t, bigSize, dstPath, prefix) + }) + }) + return little, big +} + +func rawTest(t *testing.T, ctx *APITestContext, little, big, littleLFS, bigLFS string) { + t.Run("Raw", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + username := ctx.Username + reponame := ctx.Reponame + + session := loginUser(t, username) + + // Request raw paths + req := NewRequest(t, "GET", path.Join("/", username, reponame, "/raw/branch/master/", little)) + resp := session.MakeRequestNilResponseRecorder(t, req, http.StatusOK) + assert.Equal(t, littleSize, resp.Length) + + if setting.LFS.StartServer { + req = NewRequest(t, "GET", path.Join("/", username, reponame, "/raw/branch/master/", littleLFS)) + resp := session.MakeRequest(t, req, http.StatusOK) + assert.NotEqual(t, littleSize, resp.Body.Len()) + assert.LessOrEqual(t, resp.Body.Len(), 1024) + if resp.Body.Len() != littleSize && resp.Body.Len() <= 1024 { + assert.Contains(t, resp.Body.String(), lfs.MetaFileIdentifier) + } + } + + if !testing.Short() { + req = NewRequest(t, "GET", path.Join("/", username, reponame, "/raw/branch/master/", big)) + resp := session.MakeRequestNilResponseRecorder(t, req, http.StatusOK) + assert.Equal(t, bigSize, resp.Length) + + if setting.LFS.StartServer { + req = NewRequest(t, "GET", path.Join("/", username, reponame, "/raw/branch/master/", bigLFS)) + resp := session.MakeRequest(t, req, http.StatusOK) + assert.NotEqual(t, bigSize, resp.Body.Len()) + if resp.Body.Len() != bigSize && resp.Body.Len() <= 1024 { + assert.Contains(t, resp.Body.String(), lfs.MetaFileIdentifier) + } + } + } + }) +} + +func mediaTest(t *testing.T, ctx *APITestContext, little, big, littleLFS, bigLFS string) { + t.Run("Media", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + username := ctx.Username + reponame := ctx.Reponame + + session := loginUser(t, username) + + // Request media paths + req := NewRequest(t, "GET", path.Join("/", username, reponame, "/media/branch/master/", little)) + resp := session.MakeRequestNilResponseRecorder(t, req, http.StatusOK) + assert.Equal(t, littleSize, resp.Length) + + req = NewRequest(t, "GET", path.Join("/", username, reponame, "/media/branch/master/", littleLFS)) + resp = session.MakeRequestNilResponseRecorder(t, req, http.StatusOK) + assert.Equal(t, littleSize, resp.Length) + + if !testing.Short() { + req = NewRequest(t, "GET", path.Join("/", username, reponame, "/media/branch/master/", big)) + resp = session.MakeRequestNilResponseRecorder(t, req, http.StatusOK) + assert.Equal(t, bigSize, resp.Length) + + if setting.LFS.StartServer { + req = NewRequest(t, "GET", path.Join("/", username, reponame, "/media/branch/master/", bigLFS)) + resp = session.MakeRequestNilResponseRecorder(t, req, http.StatusOK) + assert.Equal(t, bigSize, resp.Length) + } + } + }) +} + +func lockTest(t *testing.T, repoPath string) { + lockFileTest(t, "README.md", repoPath) +} + +func lockFileTest(t *testing.T, filename, repoPath string) { + _, _, err := git.NewCommand(git.DefaultContext, "lfs").AddArguments("locks").RunStdString(&git.RunOpts{Dir: repoPath}) + require.NoError(t, err) + _, _, err = git.NewCommand(git.DefaultContext, "lfs").AddArguments("lock").AddDynamicArguments(filename).RunStdString(&git.RunOpts{Dir: repoPath}) + require.NoError(t, err) + _, _, err = git.NewCommand(git.DefaultContext, "lfs").AddArguments("locks").RunStdString(&git.RunOpts{Dir: repoPath}) + require.NoError(t, err) + _, _, err = git.NewCommand(git.DefaultContext, "lfs").AddArguments("unlock").AddDynamicArguments(filename).RunStdString(&git.RunOpts{Dir: repoPath}) + require.NoError(t, err) +} + +func doCommitAndPush(t *testing.T, size int, repoPath, prefix string) string { + name, err := generateCommitWithNewData(size, repoPath, "user2@example.com", "User Two", prefix) + require.NoError(t, err) + _, _, err = git.NewCommand(git.DefaultContext, "push", "origin", "master").RunStdString(&git.RunOpts{Dir: repoPath}) // Push + require.NoError(t, err) + return name +} + +func generateCommitWithNewData(size int, repoPath, email, fullName, prefix string) (string, error) { + // Generate random file + bufSize := 4 * 1024 + if bufSize > size { + bufSize = size + } + + buffer := make([]byte, bufSize) + + tmpFile, err := os.CreateTemp(repoPath, prefix) + if err != nil { + return "", err + } + defer tmpFile.Close() + written := 0 + for written < size { + n := size - written + if n > bufSize { + n = bufSize + } + _, err := rand.Read(buffer[:n]) + if err != nil { + return "", err + } + n, err = tmpFile.Write(buffer[:n]) + if err != nil { + return "", err + } + written += n + } + + // Commit + // Now here we should explicitly allow lfs filters to run + globalArgs := git.AllowLFSFiltersArgs() + err = git.AddChangesWithArgs(repoPath, globalArgs, false, filepath.Base(tmpFile.Name())) + if err != nil { + return "", err + } + err = git.CommitChangesWithArgs(repoPath, globalArgs, git.CommitChangesOptions{ + Committer: &git.Signature{ + Email: email, + Name: fullName, + When: time.Now(), + }, + Author: &git.Signature{ + Email: email, + Name: fullName, + When: time.Now(), + }, + Message: fmt.Sprintf("Testing commit @ %v", time.Now()), + }) + return filepath.Base(tmpFile.Name()), err +} + +func doBranchProtect(baseCtx *APITestContext, dstPath string) func(t *testing.T) { + return func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + t.Run("CreateBranchProtected", doGitCreateBranch(dstPath, "protected")) + t.Run("PushProtectedBranch", doGitPushTestRepository(dstPath, "origin", "protected")) + + ctx := NewAPITestContext(t, baseCtx.Username, baseCtx.Reponame, auth_model.AccessTokenScopeWriteRepository) + + t.Run("PushToNewProtectedBranch", func(t *testing.T) { + t.Run("CreateBranchProtected", doGitCreateBranch(dstPath, "before-create-1")) + t.Run("ProtectProtectedBranch", doProtectBranch(ctx, "before-create-1", parameterProtectBranch{ + "enable_push": "all", + "apply_to_admins": "on", + })) + t.Run("PushProtectedBranch", doGitPushTestRepository(dstPath, "origin", "before-create-1")) + + t.Run("GenerateCommit", func(t *testing.T) { + _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "protected-file-data-") + require.NoError(t, err) + }) + + t.Run("ProtectProtectedBranch", doProtectBranch(ctx, "before-create-2", parameterProtectBranch{ + "enable_push": "all", + "protected_file_patterns": "protected-file-data-*", + "apply_to_admins": "on", + })) + + doGitPushTestRepositoryFail(dstPath, "origin", "HEAD:before-create-2")(t) + }) + + t.Run("FailToPushToProtectedBranch", func(t *testing.T) { + t.Run("ProtectProtectedBranch", doProtectBranch(ctx, "protected")) + t.Run("Create modified-protected-branch", doGitCheckoutBranch(dstPath, "-b", "modified-protected-branch", "protected")) + t.Run("GenerateCommit", func(t *testing.T) { + _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "branch-data-file-") + require.NoError(t, err) + }) + + doGitPushTestRepositoryFail(dstPath, "origin", "modified-protected-branch:protected")(t) + }) + + t.Run("PushToUnprotectedBranch", doGitPushTestRepository(dstPath, "origin", "modified-protected-branch:unprotected")) + + t.Run("FailToPushProtectedFilesToProtectedBranch", func(t *testing.T) { + t.Run("Create modified-protected-file-protected-branch", doGitCheckoutBranch(dstPath, "-b", "modified-protected-file-protected-branch", "protected")) + t.Run("GenerateCommit", func(t *testing.T) { + _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "protected-file-") + require.NoError(t, err) + }) + + t.Run("ProtectedFilePathsApplyToAdmins", doProtectBranch(ctx, "protected")) + doGitPushTestRepositoryFail(dstPath, "origin", "modified-protected-file-protected-branch:protected")(t) + + doGitCheckoutBranch(dstPath, "protected")(t) + doGitPull(dstPath, "origin", "protected")(t) + }) + + t.Run("PushUnprotectedFilesToProtectedBranch", func(t *testing.T) { + t.Run("Create modified-unprotected-file-protected-branch", doGitCheckoutBranch(dstPath, "-b", "modified-unprotected-file-protected-branch", "protected")) + t.Run("UnprotectedFilePaths", doProtectBranch(ctx, "protected", parameterProtectBranch{ + "unprotected_file_patterns": "unprotected-file-*", + })) + t.Run("GenerateCommit", func(t *testing.T) { + _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "unprotected-file-") + require.NoError(t, err) + }) + doGitPushTestRepository(dstPath, "origin", "modified-unprotected-file-protected-branch:protected")(t) + doGitCheckoutBranch(dstPath, "protected")(t) + doGitPull(dstPath, "origin", "protected")(t) + }) + + user, err := user_model.GetUserByName(db.DefaultContext, baseCtx.Username) + require.NoError(t, err) + t.Run("WhitelistUsers", doProtectBranch(ctx, "protected", parameterProtectBranch{ + "enable_push": "whitelist", + "enable_whitelist": "on", + "whitelist_users": strconv.FormatInt(user.ID, 10), + })) + + t.Run("WhitelistedUserFailToForcePushToProtectedBranch", func(t *testing.T) { + t.Run("Create toforce", doGitCheckoutBranch(dstPath, "-b", "toforce", "master")) + t.Run("GenerateCommit", func(t *testing.T) { + _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "branch-data-file-") + require.NoError(t, err) + }) + doGitPushTestRepositoryFail(dstPath, "-f", "origin", "toforce:protected")(t) + }) + + t.Run("WhitelistedUserPushToProtectedBranch", func(t *testing.T) { + t.Run("Create topush", doGitCheckoutBranch(dstPath, "-b", "topush", "protected")) + t.Run("GenerateCommit", func(t *testing.T) { + _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "branch-data-file-") + require.NoError(t, err) + }) + doGitPushTestRepository(dstPath, "origin", "topush:protected")(t) + }) + } +} + +type parameterProtectBranch map[string]string + +func doProtectBranch(ctx APITestContext, branch string, addParameter ...parameterProtectBranch) func(t *testing.T) { + // We are going to just use the owner to set the protection. + return func(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: ctx.Reponame, OwnerName: ctx.Username}) + rule := &git_model.ProtectedBranch{RuleName: branch, RepoID: repo.ID} + unittest.LoadBeanIfExists(rule) + + csrf := GetCSRF(t, ctx.Session, fmt.Sprintf("/%s/%s/settings/branches", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame))) + + parameter := parameterProtectBranch{ + "_csrf": csrf, + "rule_id": strconv.FormatInt(rule.ID, 10), + "rule_name": branch, + } + if len(addParameter) > 0 { + for k, v := range addParameter[0] { + parameter[k] = v + } + } + + // Change branch to protected + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)), parameter) + ctx.Session.MakeRequest(t, req, http.StatusSeeOther) + // Check if master branch has been locked successfully + flashCookie := ctx.Session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "success%3DBranch%2Bprotection%2Bfor%2Brule%2B%2522"+url.QueryEscape(branch)+"%2522%2Bhas%2Bbeen%2Bupdated.", flashCookie.Value) + } +} + +func doMergeFork(ctx, baseCtx APITestContext, baseBranch, headBranch string) func(t *testing.T) { + return func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + var pr api.PullRequest + var err error + + // Create a test pull request + t.Run("CreatePullRequest", func(t *testing.T) { + pr, err = doAPICreatePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, baseBranch, headBranch)(t) + require.NoError(t, err) + }) + + // Ensure the PR page works. + // For the base repository owner, the PR is not editable (maintainer edits are not enabled): + t.Run("EnsureCanSeePull", doEnsureCanSeePull(baseCtx, pr, false)) + // For the head repository owner, the PR is editable: + headSession := loginUser(t, "user2") + headToken := getTokenForLoggedInUser(t, headSession, auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopeReadUser) + headCtx := APITestContext{ + Session: headSession, + Token: headToken, + Username: baseCtx.Username, + Reponame: baseCtx.Reponame, + } + t.Run("EnsureCanSeePull", doEnsureCanSeePull(headCtx, pr, true)) + + // Confirm that there is no AGit Label + // TODO: Refactor and move this check to a function + t.Run("AGitLabelIsMissing", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, ctx.Username) + + req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d", baseCtx.Username, baseCtx.Reponame, pr.Index)) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, "#agit-label", false) + }) + + // Then get the diff string + var diffHash string + var diffLength int + t.Run("GetDiff", func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d.diff", url.PathEscape(baseCtx.Username), url.PathEscape(baseCtx.Reponame), pr.Index)) + resp := ctx.Session.MakeRequestNilResponseHashSumRecorder(t, req, http.StatusOK) + diffHash = string(resp.Hash.Sum(nil)) + diffLength = resp.Length + }) + + // Now: Merge the PR & make sure that doesn't break the PR page or change its diff + t.Run("MergePR", doAPIMergePullRequest(baseCtx, baseCtx.Username, baseCtx.Reponame, pr.Index)) + // for both users the PR is still visible but not editable anymore after it was merged + t.Run("EnsureCanSeePull", doEnsureCanSeePull(baseCtx, pr, false)) + t.Run("EnsureCanSeePull", doEnsureCanSeePull(headCtx, pr, false)) + t.Run("CheckPR", func(t *testing.T) { + oldMergeBase := pr.MergeBase + pr2, err := doAPIGetPullRequest(baseCtx, baseCtx.Username, baseCtx.Reponame, pr.Index)(t) + require.NoError(t, err) + assert.Equal(t, oldMergeBase, pr2.MergeBase) + }) + t.Run("EnsurDiffNoChange", doEnsureDiffNoChange(baseCtx, pr, diffHash, diffLength)) + + // Then: Delete the head branch & make sure that doesn't break the PR page or change its diff + t.Run("DeleteHeadBranch", doBranchDelete(baseCtx, baseCtx.Username, baseCtx.Reponame, headBranch)) + t.Run("EnsureCanSeePull", doEnsureCanSeePull(baseCtx, pr, false)) + t.Run("EnsureDiffNoChange", doEnsureDiffNoChange(baseCtx, pr, diffHash, diffLength)) + + // Delete the head repository & make sure that doesn't break the PR page or change its diff + t.Run("DeleteHeadRepository", doAPIDeleteRepository(ctx)) + t.Run("EnsureCanSeePull", doEnsureCanSeePull(baseCtx, pr, false)) + t.Run("EnsureDiffNoChange", doEnsureDiffNoChange(baseCtx, pr, diffHash, diffLength)) + } +} + +func doCreatePRAndSetManuallyMerged(ctx, baseCtx APITestContext, dstPath, baseBranch, headBranch string) func(t *testing.T) { + return func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + var ( + pr api.PullRequest + err error + lastCommitID string + ) + + trueBool := true + falseBool := false + + t.Run("AllowSetManuallyMergedAndSwitchOffAutodetectManualMerge", doAPIEditRepository(baseCtx, &api.EditRepoOption{ + HasPullRequests: &trueBool, + AllowManualMerge: &trueBool, + AutodetectManualMerge: &falseBool, + })) + + t.Run("CreateHeadBranch", doGitCreateBranch(dstPath, headBranch)) + t.Run("PushToHeadBranch", doGitPushTestRepository(dstPath, "origin", headBranch)) + t.Run("CreateEmptyPullRequest", func(t *testing.T) { + pr, err = doAPICreatePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, baseBranch, headBranch)(t) + require.NoError(t, err) + }) + lastCommitID = pr.Base.Sha + t.Run("ManuallyMergePR", doAPIManuallyMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, lastCommitID, pr.Index)) + } +} + +func doEnsureCanSeePull(ctx APITestContext, pr api.PullRequest, editable bool) func(t *testing.T) { + return func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr.Index)) + ctx.Session.MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d/files", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr.Index)) + resp := ctx.Session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + editButtonCount := doc.doc.Find("div.diff-file-header-actions a[href*='/_edit/']").Length() + if editable { + assert.Positive(t, editButtonCount, 0, "Expected to find a button to edit a file in the PR diff view but there were none") + } else { + assert.Equal(t, 0, editButtonCount, "Expected not to find any buttons to edit files in PR diff view but there were some") + } + req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d/commits", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr.Index)) + ctx.Session.MakeRequest(t, req, http.StatusOK) + } +} + +func doEnsureDiffNoChange(ctx APITestContext, pr api.PullRequest, diffHash string, diffLength int) func(t *testing.T) { + return func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d.diff", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr.Index)) + resp := ctx.Session.MakeRequestNilResponseHashSumRecorder(t, req, http.StatusOK) + actual := string(resp.Hash.Sum(nil)) + actualLength := resp.Length + + equal := diffHash == actual + assert.True(t, equal, "Unexpected change in the diff string: expected hash: %s size: %d but was actually: %s size: %d", hex.EncodeToString([]byte(diffHash)), diffLength, hex.EncodeToString([]byte(actual)), actualLength) + } +} + +func doPushCreate(ctx APITestContext, u *url.URL, objectFormat git.ObjectFormat) func(t *testing.T) { + return func(t *testing.T) { + if objectFormat == git.Sha256ObjectFormat { + t.Skipf("push-create not supported for %s, see https://codeberg.org/forgejo/forgejo/issues/3783", objectFormat) + } + defer tests.PrintCurrentTest(t)() + + // create a context for a currently non-existent repository + ctx.Reponame = fmt.Sprintf("repo-tmp-push-create-%s", u.Scheme) + u.Path = ctx.GitPath() + + // Create a temporary directory + tmpDir := t.TempDir() + + // Now create local repository to push as our test and set its origin + t.Run("InitTestRepository", doGitInitTestRepository(tmpDir, objectFormat)) + t.Run("AddRemote", doGitAddRemote(tmpDir, "origin", u)) + + // Disable "Push To Create" and attempt to push + setting.Repository.EnablePushCreateUser = false + t.Run("FailToPushAndCreateTestRepository", doGitPushTestRepositoryFail(tmpDir, "origin", "master")) + + // Enable "Push To Create" + setting.Repository.EnablePushCreateUser = true + + // Assert that cloning from a non-existent repository does not create it and that it definitely wasn't create above + t.Run("FailToCloneFromNonExistentRepository", doGitCloneFail(u)) + + // Then "Push To Create"x + t.Run("SuccessfullyPushAndCreateTestRepository", doGitPushTestRepository(tmpDir, "origin", "master")) + + // Finally, fetch repo from database and ensure the correct repository has been created + repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, ctx.Username, ctx.Reponame) + require.NoError(t, err) + assert.False(t, repo.IsEmpty) + assert.True(t, repo.IsPrivate) + + // Now add a remote that is invalid to "Push To Create" + invalidCtx := ctx + invalidCtx.Reponame = fmt.Sprintf("invalid/repo-tmp-push-create-%s", u.Scheme) + u.Path = invalidCtx.GitPath() + t.Run("AddInvalidRemote", doGitAddRemote(tmpDir, "invalid", u)) + + // Fail to "Push To Create" the invalid + t.Run("FailToPushAndCreateInvalidTestRepository", doGitPushTestRepositoryFail(tmpDir, "invalid", "master")) + } +} + +func doBranchDelete(ctx APITestContext, owner, repo, branch string) func(*testing.T) { + return func(t *testing.T) { + csrf := GetCSRF(t, ctx.Session, fmt.Sprintf("/%s/%s/branches", url.PathEscape(owner), url.PathEscape(repo))) + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/branches/delete?name=%s", url.PathEscape(owner), url.PathEscape(repo), url.QueryEscape(branch)), map[string]string{ + "_csrf": csrf, + }) + ctx.Session.MakeRequest(t, req, http.StatusOK) + } +} + +func doAutoPRMerge(baseCtx *APITestContext, dstPath string) func(t *testing.T) { + return func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + ctx := NewAPITestContext(t, baseCtx.Username, baseCtx.Reponame, auth_model.AccessTokenScopeWriteRepository) + + t.Run("CheckoutProtected", doGitCheckoutBranch(dstPath, "protected")) + t.Run("PullProtected", doGitPull(dstPath, "origin", "protected")) + t.Run("GenerateCommit", func(t *testing.T) { + _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "branch-data-file-") + require.NoError(t, err) + }) + t.Run("PushToUnprotectedBranch", doGitPushTestRepository(dstPath, "origin", "protected:unprotected3")) + var pr api.PullRequest + var err error + t.Run("CreatePullRequest", func(t *testing.T) { + pr, err = doAPICreatePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, "protected", "unprotected3")(t) + require.NoError(t, err) + }) + + // Request repository commits page + req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d/commits", baseCtx.Username, baseCtx.Reponame, pr.Index)) + resp := ctx.Session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + // Get first commit URL + commitURL, exists := doc.doc.Find("#commits-table tbody tr td.sha a").Last().Attr("href") + assert.True(t, exists) + assert.NotEmpty(t, commitURL) + + commitID := path.Base(commitURL) + + addCommitStatus := func(status api.CommitStatusState) func(*testing.T) { + return doAPICreateCommitStatus(ctx, commitID, api.CreateStatusOption{ + State: status, + TargetURL: "http://test.ci/", + Description: "", + Context: "testci", + }) + } + + // Call API to add Pending status for commit + t.Run("CreateStatus", addCommitStatus(api.CommitStatusPending)) + + // Cancel not existing auto merge + ctx.ExpectedCode = http.StatusNotFound + t.Run("CancelAutoMergePR", doAPICancelAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)) + + // Add auto merge request + ctx.ExpectedCode = http.StatusCreated + t.Run("AutoMergePR", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)) + + // Can not create schedule twice + ctx.ExpectedCode = http.StatusConflict + t.Run("AutoMergePRTwice", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)) + + // Cancel auto merge request + ctx.ExpectedCode = http.StatusNoContent + t.Run("CancelAutoMergePR", doAPICancelAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)) + + // Add auto merge request + ctx.ExpectedCode = http.StatusCreated + t.Run("AutoMergePR", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)) + + // Check pr status + ctx.ExpectedCode = 0 + pr, err = doAPIGetPullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)(t) + require.NoError(t, err) + assert.False(t, pr.HasMerged) + + // Call API to add Failure status for commit + t.Run("CreateStatus", addCommitStatus(api.CommitStatusFailure)) + + // Check pr status + pr, err = doAPIGetPullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)(t) + require.NoError(t, err) + assert.False(t, pr.HasMerged) + + // Call API to add Success status for commit + t.Run("CreateStatus", addCommitStatus(api.CommitStatusSuccess)) + + // wait to let gitea merge stuff + time.Sleep(time.Second) + + // test pr status + pr, err = doAPIGetPullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)(t) + require.NoError(t, err) + assert.True(t, pr.HasMerged) + } +} + +func doInternalReferences(ctx *APITestContext, dstPath string) func(t *testing.T) { + return func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: ctx.Username, Name: ctx.Reponame}) + pr1 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{HeadRepoID: repo.ID}) + + _, stdErr, gitErr := git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments(fmt.Sprintf(":refs/pull/%d/head", pr1.Index)).RunStdString(&git.RunOpts{Dir: dstPath}) + require.Error(t, gitErr) + assert.Contains(t, stdErr, fmt.Sprintf("remote: Forgejo: The deletion of refs/pull/%d/head is skipped as it's an internal reference.", pr1.Index)) + assert.Contains(t, stdErr, fmt.Sprintf("[remote rejected] refs/pull/%d/head (hook declined)", pr1.Index)) + } +} + +func doCreateAgitFlowPull(dstPath string, ctx *APITestContext, headBranch string) func(t *testing.T) { + return func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // skip this test if git version is low + if git.CheckGitVersionAtLeast("2.29") != nil { + return + } + + gitRepo, err := git.OpenRepository(git.DefaultContext, dstPath) + require.NoError(t, err) + + defer gitRepo.Close() + + var ( + pr1, pr2 *issues_model.PullRequest + commit string + ) + repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, ctx.Username, ctx.Reponame) + require.NoError(t, err) + + pullNum := unittest.GetCount(t, &issues_model.PullRequest{}) + + t.Run("CreateHeadBranch", doGitCreateBranch(dstPath, headBranch)) + + t.Run("AddCommit", func(t *testing.T) { + err := os.WriteFile(path.Join(dstPath, "test_file"), []byte("## test content"), 0o666) + require.NoError(t, err) + + err = git.AddChanges(dstPath, true) + require.NoError(t, err) + + err = git.CommitChanges(dstPath, git.CommitChangesOptions{ + Committer: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Author: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Message: "Testing commit 1", + }) + require.NoError(t, err) + commit, err = gitRepo.GetRefCommitID("HEAD") + require.NoError(t, err) + }) + + t.Run("Push", func(t *testing.T) { + err := git.NewCommand(git.DefaultContext, "push", "origin", "HEAD:refs/for/master", "-o").AddDynamicArguments("topic=" + headBranch).Run(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+1) + pr1 = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ + HeadRepoID: repo.ID, + Flow: issues_model.PullRequestFlowAGit, + }) + if !assert.NotEmpty(t, pr1) { + return + } + assert.Equal(t, 1, pr1.CommitsAhead) + assert.Equal(t, 0, pr1.CommitsBehind) + + prMsg, err := doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr1.Index)(t) + require.NoError(t, err) + + assert.Equal(t, "user2/"+headBranch, pr1.HeadBranch) + assert.False(t, prMsg.HasMerged) + assert.Contains(t, "Testing commit 1", prMsg.Body) + assert.Equal(t, commit, prMsg.Head.Sha) + + _, _, err = git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments("HEAD:refs/for/master/test/" + headBranch).RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+2) + pr2 = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ + HeadRepoID: repo.ID, + Index: pr1.Index + 1, + Flow: issues_model.PullRequestFlowAGit, + }) + if !assert.NotEmpty(t, pr2) { + return + } + assert.Equal(t, 1, pr2.CommitsAhead) + assert.Equal(t, 0, pr2.CommitsBehind) + prMsg, err = doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr2.Index)(t) + require.NoError(t, err) + + assert.Equal(t, "user2/test/"+headBranch, pr2.HeadBranch) + assert.False(t, prMsg.HasMerged) + }) + + if pr1 == nil || pr2 == nil { + return + } + + t.Run("AGitLabelIsPresent", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, ctx.Username) + + req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr2.Index)) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, "#agit-label", true) + }) + + t.Run("AddCommit2", func(t *testing.T) { + err := os.WriteFile(path.Join(dstPath, "test_file"), []byte("## test content \n ## test content 2"), 0o666) + require.NoError(t, err) + + err = git.AddChanges(dstPath, true) + require.NoError(t, err) + + err = git.CommitChanges(dstPath, git.CommitChangesOptions{ + Committer: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Author: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Message: "Testing commit 2\n\nLonger description.", + }) + require.NoError(t, err) + commit, err = gitRepo.GetRefCommitID("HEAD") + require.NoError(t, err) + }) + + t.Run("Push2", func(t *testing.T) { + err := git.NewCommand(git.DefaultContext, "push", "origin", "HEAD:refs/for/master", "-o").AddDynamicArguments("topic=" + headBranch).Run(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+2) + prMsg, err := doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr1.Index)(t) + require.NoError(t, err) + + assert.False(t, prMsg.HasMerged) + assert.Equal(t, commit, prMsg.Head.Sha) + + pr1 = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ + HeadRepoID: repo.ID, + Flow: issues_model.PullRequestFlowAGit, + Index: pr1.Index, + }) + assert.Equal(t, 2, pr1.CommitsAhead) + assert.Equal(t, 0, pr1.CommitsBehind) + + _, _, err = git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments("HEAD:refs/for/master/test/" + headBranch).RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+2) + prMsg, err = doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr2.Index)(t) + require.NoError(t, err) + + assert.False(t, prMsg.HasMerged) + assert.Equal(t, commit, prMsg.Head.Sha) + }) + t.Run("PushParams", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + t.Run("NoParams", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + _, _, gitErr := git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments("HEAD:refs/for/master/" + headBranch + "-implicit").RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, gitErr) + + unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+3) + pr3 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ + HeadRepoID: repo.ID, + Flow: issues_model.PullRequestFlowAGit, + Index: pr1.Index + 2, + }) + assert.NotEmpty(t, pr3) + err := pr3.LoadIssue(db.DefaultContext) + require.NoError(t, err) + + _, err2 := doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr3.Index)(t) + require.NoError(t, err2) + + assert.Equal(t, "Testing commit 2", pr3.Issue.Title) + assert.Contains(t, pr3.Issue.Content, "Longer description.") + }) + t.Run("TitleOverride", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + _, _, gitErr := git.NewCommand(git.DefaultContext, "push", "origin", "-o", "title=my-shiny-title").AddDynamicArguments("HEAD:refs/for/master/" + headBranch + "-implicit-2").RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, gitErr) + + unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+4) + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ + HeadRepoID: repo.ID, + Flow: issues_model.PullRequestFlowAGit, + Index: pr1.Index + 3, + }) + assert.NotEmpty(t, pr) + err := pr.LoadIssue(db.DefaultContext) + require.NoError(t, err) + + _, err = doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr.Index)(t) + require.NoError(t, err) + + assert.Equal(t, "my-shiny-title", pr.Issue.Title) + assert.Contains(t, pr.Issue.Content, "Longer description.") + }) + + t.Run("DescriptionOverride", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + _, _, gitErr := git.NewCommand(git.DefaultContext, "push", "origin", "-o", "description=custom").AddDynamicArguments("HEAD:refs/for/master/" + headBranch + "-implicit-3").RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, gitErr) + + unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+5) + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ + HeadRepoID: repo.ID, + Flow: issues_model.PullRequestFlowAGit, + Index: pr1.Index + 4, + }) + assert.NotEmpty(t, pr) + err := pr.LoadIssue(db.DefaultContext) + require.NoError(t, err) + + _, err = doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr.Index)(t) + require.NoError(t, err) + + assert.Equal(t, "Testing commit 2", pr.Issue.Title) + assert.Contains(t, pr.Issue.Content, "custom") + }) + }) + + upstreamGitRepo, err := git.OpenRepository(git.DefaultContext, filepath.Join(setting.RepoRootPath, ctx.Username, ctx.Reponame+".git")) + require.NoError(t, err) + defer upstreamGitRepo.Close() + + t.Run("Force push", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + _, _, gitErr := git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments("HEAD:refs/for/master/" + headBranch + "-force-push").RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, gitErr) + + unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+6) + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ + HeadRepoID: repo.ID, + Flow: issues_model.PullRequestFlowAGit, + Index: pr1.Index + 5, + }) + + headCommitID, err := upstreamGitRepo.GetRefCommitID(pr.GetGitRefName()) + require.NoError(t, err) + + _, _, gitErr = git.NewCommand(git.DefaultContext, "reset", "--hard", "HEAD~1").RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, gitErr) + + t.Run("Fails", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + _, stdErr, gitErr := git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments("HEAD:refs/for/master/" + headBranch + "-force-push").RunStdString(&git.RunOpts{Dir: dstPath}) + require.Error(t, gitErr) + + assert.Contains(t, stdErr, "-o force-push=true") + + currentHeadCommitID, err := upstreamGitRepo.GetRefCommitID(pr.GetGitRefName()) + require.NoError(t, err) + assert.EqualValues(t, headCommitID, currentHeadCommitID) + }) + t.Run("Succeeds", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + _, _, gitErr := git.NewCommand(git.DefaultContext, "push", "origin", "-o", "force-push").AddDynamicArguments("HEAD:refs/for/master/" + headBranch + "-force-push").RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, gitErr) + + currentHeadCommitID, err := upstreamGitRepo.GetRefCommitID(pr.GetGitRefName()) + require.NoError(t, err) + assert.NotEqualValues(t, headCommitID, currentHeadCommitID) + }) + }) + + t.Run("Branch already contains commit", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + branchCommit, err := upstreamGitRepo.GetBranchCommit("master") + require.NoError(t, err) + + _, _, gitErr := git.NewCommand(git.DefaultContext, "reset", "--hard").AddDynamicArguments(branchCommit.ID.String() + "~1").RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, gitErr) + + _, stdErr, gitErr := git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments("HEAD:refs/for/master/" + headBranch + "-already-contains").RunStdString(&git.RunOpts{Dir: dstPath}) + require.Error(t, gitErr) + + assert.Contains(t, stdErr, "already contains this commit") + }) + + t.Run("Merge", doAPIMergePullRequest(*ctx, ctx.Username, ctx.Reponame, pr1.Index)) + + t.Run("AGitLabelIsPresent Merged", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, ctx.Username) + + req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr2.Index)) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, "#agit-label", true) + }) + + t.Run("CheckoutMasterAgain", doGitCheckoutBranch(dstPath, "master")) + } +} + +func TestDataAsync_Issue29101(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + resp, err := files_service.ChangeRepoFiles(db.DefaultContext, repo, user, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "test.txt", + ContentReader: bytes.NewReader(make([]byte, 10000)), + }, + }, + OldBranch: repo.DefaultBranch, + NewBranch: repo.DefaultBranch, + }) + require.NoError(t, err) + + sha := resp.Commit.SHA + + gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo) + require.NoError(t, err) + defer gitRepo.Close() + + commit, err := gitRepo.GetCommit(sha) + require.NoError(t, err) + + entry, err := commit.GetTreeEntryByPath("test.txt") + require.NoError(t, err) + + b := entry.Blob() + + r, err := b.DataAsync() + require.NoError(t, err) + defer r.Close() + + r2, err := b.DataAsync() + require.NoError(t, err) + defer r2.Close() + }) +} diff --git a/tests/integration/goget_test.go b/tests/integration/goget_test.go new file mode 100644 index 0000000..854f8d7 --- /dev/null +++ b/tests/integration/goget_test.go @@ -0,0 +1,61 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestGoGet(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/blah/glah/plah?go-get=1") + resp := MakeRequest(t, req, http.StatusOK) + + expected := fmt.Sprintf(`<!doctype html> +<html> + <head> + <meta name="go-import" content="%[1]s:%[2]s/blah/glah git %[3]sblah/glah.git"> + <meta name="go-source" content="%[1]s:%[2]s/blah/glah _ %[3]sblah/glah/src/branch/master{/dir} %[3]sblah/glah/src/branch/master{/dir}/{file}#L{line}"> + </head> + <body> + go get --insecure %[1]s:%[2]s/blah/glah + </body> +</html>`, setting.Domain, setting.HTTPPort, setting.AppURL) + + assert.Equal(t, expected, resp.Body.String()) +} + +func TestGoGetForSSH(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + old := setting.Repository.GoGetCloneURLProtocol + defer func() { + setting.Repository.GoGetCloneURLProtocol = old + }() + setting.Repository.GoGetCloneURLProtocol = "ssh" + + req := NewRequest(t, "GET", "/blah/glah/plah?go-get=1") + resp := MakeRequest(t, req, http.StatusOK) + + expected := fmt.Sprintf(`<!doctype html> +<html> + <head> + <meta name="go-import" content="%[1]s:%[2]s/blah/glah git ssh://git@%[4]s:%[5]d/blah/glah.git"> + <meta name="go-source" content="%[1]s:%[2]s/blah/glah _ %[3]sblah/glah/src/branch/master{/dir} %[3]sblah/glah/src/branch/master{/dir}/{file}#L{line}"> + </head> + <body> + go get --insecure %[1]s:%[2]s/blah/glah + </body> +</html>`, setting.Domain, setting.HTTPPort, setting.AppURL, setting.SSH.Domain, setting.SSH.Port) + + assert.Equal(t, expected, resp.Body.String()) +} diff --git a/tests/integration/gpg_git_test.go b/tests/integration/gpg_git_test.go new file mode 100644 index 0000000..5302997 --- /dev/null +++ b/tests/integration/gpg_git_test.go @@ -0,0 +1,304 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "fmt" + "net/url" + "os" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/tests" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGPGGit(t *testing.T) { + tmpDir := t.TempDir() // use a temp dir to avoid messing with the user's GPG keyring + err := os.Chmod(tmpDir, 0o700) + require.NoError(t, err) + + t.Setenv("GNUPGHOME", tmpDir) + require.NoError(t, err) + + // Need to create a root key + rootKeyPair, err := importTestingKey() + require.NoError(t, err, "importTestingKey") + + defer test.MockVariableValue(&setting.Repository.Signing.SigningKey, rootKeyPair.PrimaryKey.KeyIdShortString())() + defer test.MockVariableValue(&setting.Repository.Signing.SigningName, "gitea")() + defer test.MockVariableValue(&setting.Repository.Signing.SigningEmail, "gitea@fake.local")() + defer test.MockVariableValue(&setting.Repository.Signing.InitialCommit, []string{"never"})() + defer test.MockVariableValue(&setting.Repository.Signing.CRUDActions, []string{"never"})() + + username := "user2" + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username}) + baseAPITestContext := NewAPITestContext(t, username, "repo1") + + onGiteaRun(t, func(t *testing.T, u *url.URL) { + u.Path = baseAPITestContext.GitPath() + + t.Run("Unsigned-Initial", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCtx := NewAPITestContext(t, username, "initial-unsigned", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + t.Run("CreateRepository", doAPICreateRepository(testCtx, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat + t.Run("CheckMasterBranchUnsigned", doAPIGetBranch(testCtx, "master", func(t *testing.T, branch api.Branch) { + assert.NotNil(t, branch.Commit) + assert.NotNil(t, branch.Commit.Verification) + assert.False(t, branch.Commit.Verification.Verified) + assert.Empty(t, branch.Commit.Verification.Signature) + })) + t.Run("CreateCRUDFile-Never", crudActionCreateFile( + t, testCtx, user, "master", "never", "unsigned-never.txt", func(t *testing.T, response api.FileResponse) { + assert.False(t, response.Verification.Verified) + })) + t.Run("CreateCRUDFile-Never", crudActionCreateFile( + t, testCtx, user, "never", "never2", "unsigned-never2.txt", func(t *testing.T, response api.FileResponse) { + assert.False(t, response.Verification.Verified) + })) + }) + + setting.Repository.Signing.CRUDActions = []string{"parentsigned"} + t.Run("Unsigned-Initial-CRUD-ParentSigned", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCtx := NewAPITestContext(t, username, "initial-unsigned", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + t.Run("CreateCRUDFile-ParentSigned", crudActionCreateFile( + t, testCtx, user, "master", "parentsigned", "signed-parent.txt", func(t *testing.T, response api.FileResponse) { + assert.False(t, response.Verification.Verified) + })) + t.Run("CreateCRUDFile-ParentSigned", crudActionCreateFile( + t, testCtx, user, "parentsigned", "parentsigned2", "signed-parent2.txt", func(t *testing.T, response api.FileResponse) { + assert.False(t, response.Verification.Verified) + })) + }) + + setting.Repository.Signing.CRUDActions = []string{"never"} + t.Run("Unsigned-Initial-CRUD-Never", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCtx := NewAPITestContext(t, username, "initial-unsigned", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + t.Run("CreateCRUDFile-Never", crudActionCreateFile( + t, testCtx, user, "parentsigned", "parentsigned-never", "unsigned-never2.txt", func(t *testing.T, response api.FileResponse) { + assert.False(t, response.Verification.Verified) + })) + }) + + setting.Repository.Signing.CRUDActions = []string{"always"} + t.Run("Unsigned-Initial-CRUD-Always", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCtx := NewAPITestContext(t, username, "initial-unsigned", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + t.Run("CreateCRUDFile-Always", crudActionCreateFile( + t, testCtx, user, "master", "always", "signed-always.txt", func(t *testing.T, response api.FileResponse) { + assert.NotNil(t, response.Verification) + if response.Verification == nil { + assert.FailNow(t, "no verification provided with response! %v", response) + } + assert.True(t, response.Verification.Verified) + if !response.Verification.Verified { + t.FailNow() + } + assert.Equal(t, "gitea@fake.local", response.Verification.Signer.Email) + })) + t.Run("CreateCRUDFile-ParentSigned-always", crudActionCreateFile( + t, testCtx, user, "parentsigned", "parentsigned-always", "signed-parent2.txt", func(t *testing.T, response api.FileResponse) { + assert.NotNil(t, response.Verification) + if response.Verification == nil { + assert.FailNow(t, "no verification provided with response! %v", response) + } + assert.True(t, response.Verification.Verified) + if !response.Verification.Verified { + t.FailNow() + } + assert.Equal(t, "gitea@fake.local", response.Verification.Signer.Email) + })) + }) + + setting.Repository.Signing.CRUDActions = []string{"parentsigned"} + t.Run("Unsigned-Initial-CRUD-ParentSigned", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCtx := NewAPITestContext(t, username, "initial-unsigned", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + t.Run("CreateCRUDFile-Always-ParentSigned", crudActionCreateFile( + t, testCtx, user, "always", "always-parentsigned", "signed-always-parentsigned.txt", func(t *testing.T, response api.FileResponse) { + assert.NotNil(t, response.Verification) + if response.Verification == nil { + assert.FailNow(t, "no verification provided with response! %v", response) + } + assert.True(t, response.Verification.Verified) + if !response.Verification.Verified { + t.FailNow() + } + assert.Equal(t, "gitea@fake.local", response.Verification.Signer.Email) + })) + }) + + setting.Repository.Signing.InitialCommit = []string{"always"} + t.Run("AlwaysSign-Initial", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCtx := NewAPITestContext(t, username, "initial-always", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + t.Run("CreateRepository", doAPICreateRepository(testCtx, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat + t.Run("CheckMasterBranchSigned", doAPIGetBranch(testCtx, "master", func(t *testing.T, branch api.Branch) { + assert.NotNil(t, branch.Commit) + if branch.Commit == nil { + assert.FailNow(t, "no commit provided with branch! %v", branch) + } + assert.NotNil(t, branch.Commit.Verification) + if branch.Commit.Verification == nil { + assert.FailNow(t, "no verification provided with branch commit! %v", branch.Commit) + } + assert.True(t, branch.Commit.Verification.Verified) + if !branch.Commit.Verification.Verified { + t.FailNow() + } + assert.Equal(t, "gitea@fake.local", branch.Commit.Verification.Signer.Email) + })) + }) + + setting.Repository.Signing.CRUDActions = []string{"never"} + t.Run("AlwaysSign-Initial-CRUD-Never", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCtx := NewAPITestContext(t, username, "initial-always-never", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + t.Run("CreateRepository", doAPICreateRepository(testCtx, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat + t.Run("CreateCRUDFile-Never", crudActionCreateFile( + t, testCtx, user, "master", "never", "unsigned-never.txt", func(t *testing.T, response api.FileResponse) { + assert.False(t, response.Verification.Verified) + })) + }) + + setting.Repository.Signing.CRUDActions = []string{"parentsigned"} + t.Run("AlwaysSign-Initial-CRUD-ParentSigned-On-Always", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCtx := NewAPITestContext(t, username, "initial-always-parent", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + t.Run("CreateRepository", doAPICreateRepository(testCtx, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat + t.Run("CreateCRUDFile-ParentSigned", crudActionCreateFile( + t, testCtx, user, "master", "parentsigned", "signed-parent.txt", func(t *testing.T, response api.FileResponse) { + assert.True(t, response.Verification.Verified) + if !response.Verification.Verified { + t.FailNow() + return + } + assert.Equal(t, "gitea@fake.local", response.Verification.Signer.Email) + })) + }) + + setting.Repository.Signing.CRUDActions = []string{"always"} + t.Run("AlwaysSign-Initial-CRUD-Always", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCtx := NewAPITestContext(t, username, "initial-always-always", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + t.Run("CreateRepository", doAPICreateRepository(testCtx, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat + t.Run("CreateCRUDFile-Always", crudActionCreateFile( + t, testCtx, user, "master", "always", "signed-always.txt", func(t *testing.T, response api.FileResponse) { + assert.True(t, response.Verification.Verified) + if !response.Verification.Verified { + t.FailNow() + return + } + assert.Equal(t, "gitea@fake.local", response.Verification.Signer.Email) + })) + }) + + setting.Repository.Signing.Merges = []string{"commitssigned"} + t.Run("UnsignedMerging", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCtx := NewAPITestContext(t, username, "initial-unsigned", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + t.Run("CreatePullRequest", func(t *testing.T) { + pr, err := doAPICreatePullRequest(testCtx, testCtx.Username, testCtx.Reponame, "master", "never2")(t) + require.NoError(t, err) + t.Run("MergePR", doAPIMergePullRequest(testCtx, testCtx.Username, testCtx.Reponame, pr.Index)) + }) + t.Run("CheckMasterBranchUnsigned", doAPIGetBranch(testCtx, "master", func(t *testing.T, branch api.Branch) { + assert.NotNil(t, branch.Commit) + assert.NotNil(t, branch.Commit.Verification) + assert.False(t, branch.Commit.Verification.Verified) + assert.Empty(t, branch.Commit.Verification.Signature) + })) + }) + + setting.Repository.Signing.Merges = []string{"basesigned"} + t.Run("BaseSignedMerging", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCtx := NewAPITestContext(t, username, "initial-unsigned", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + t.Run("CreatePullRequest", func(t *testing.T) { + pr, err := doAPICreatePullRequest(testCtx, testCtx.Username, testCtx.Reponame, "master", "parentsigned2")(t) + require.NoError(t, err) + t.Run("MergePR", doAPIMergePullRequest(testCtx, testCtx.Username, testCtx.Reponame, pr.Index)) + }) + t.Run("CheckMasterBranchUnsigned", doAPIGetBranch(testCtx, "master", func(t *testing.T, branch api.Branch) { + assert.NotNil(t, branch.Commit) + assert.NotNil(t, branch.Commit.Verification) + assert.False(t, branch.Commit.Verification.Verified) + assert.Empty(t, branch.Commit.Verification.Signature) + })) + }) + + setting.Repository.Signing.Merges = []string{"commitssigned"} + t.Run("CommitsSignedMerging", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCtx := NewAPITestContext(t, username, "initial-unsigned", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + t.Run("CreatePullRequest", func(t *testing.T) { + pr, err := doAPICreatePullRequest(testCtx, testCtx.Username, testCtx.Reponame, "master", "always-parentsigned")(t) + require.NoError(t, err) + t.Run("MergePR", doAPIMergePullRequest(testCtx, testCtx.Username, testCtx.Reponame, pr.Index)) + }) + t.Run("CheckMasterBranchUnsigned", doAPIGetBranch(testCtx, "master", func(t *testing.T, branch api.Branch) { + assert.NotNil(t, branch.Commit) + assert.NotNil(t, branch.Commit.Verification) + assert.True(t, branch.Commit.Verification.Verified) + })) + }) + }) +} + +func crudActionCreateFile(_ *testing.T, ctx APITestContext, user *user_model.User, from, to, path string, callback ...func(*testing.T, api.FileResponse)) func(*testing.T) { + return doAPICreateFile(ctx, path, &api.CreateFileOptions{ + FileOptions: api.FileOptions{ + BranchName: from, + NewBranchName: to, + Message: fmt.Sprintf("from:%s to:%s path:%s", from, to, path), + Author: api.Identity{ + Name: user.FullName, + Email: user.Email, + }, + Committer: api.Identity{ + Name: user.FullName, + Email: user.Email, + }, + }, + ContentBase64: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("This is new text for %s", path))), + }, callback...) +} + +func importTestingKey() (*openpgp.Entity, error) { + if _, _, err := process.GetManager().Exec("gpg --import tests/integration/private-testing.key", "gpg", "--import", "tests/integration/private-testing.key"); err != nil { + return nil, err + } + keyringFile, err := os.Open("tests/integration/private-testing.key") + if err != nil { + return nil, err + } + defer keyringFile.Close() + + block, err := armor.Decode(keyringFile) + if err != nil { + return nil, err + } + + keyring, err := openpgp.ReadKeyRing(block.Body) + if err != nil { + return nil, fmt.Errorf("Keyring access failed: '%w'", err) + } + + // There should only be one entity in this file. + return keyring[0], nil +} diff --git a/tests/integration/html_helper.go b/tests/integration/html_helper.go new file mode 100644 index 0000000..802dcb0 --- /dev/null +++ b/tests/integration/html_helper.go @@ -0,0 +1,92 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "fmt" + "testing" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// HTMLDoc struct +type HTMLDoc struct { + doc *goquery.Document +} + +// NewHTMLParser parse html file +func NewHTMLParser(t testing.TB, body *bytes.Buffer) *HTMLDoc { + t.Helper() + doc, err := goquery.NewDocumentFromReader(body) + require.NoError(t, err) + return &HTMLDoc{doc: doc} +} + +// GetInputValueByID for get input value by id +func (doc *HTMLDoc) GetInputValueByID(id string) string { + text, _ := doc.doc.Find("#" + id).Attr("value") + return text +} + +// GetInputValueByName for get input value by name +func (doc *HTMLDoc) GetInputValueByName(name string) string { + text, _ := doc.doc.Find("input[name=\"" + name + "\"]").Attr("value") + return text +} + +func (doc *HTMLDoc) AssertDropdown(t testing.TB, name string) *goquery.Selection { + t.Helper() + + dropdownGroup := doc.Find(fmt.Sprintf(".dropdown:has(input[name='%s'])", name)) + assert.Equal(t, 1, dropdownGroup.Length(), "%s dropdown does not exist", name) + return dropdownGroup +} + +// Assert that a dropdown has at least one non-empty option +func (doc *HTMLDoc) AssertDropdownHasOptions(t testing.TB, dropdownName string) { + t.Helper() + + options := doc.AssertDropdown(t, dropdownName).Find(".menu [data-value]:not([data-value=''])") + assert.Positive(t, options.Length(), 0, fmt.Sprintf("%s dropdown has no options", dropdownName)) +} + +func (doc *HTMLDoc) AssertDropdownHasSelectedOption(t testing.TB, dropdownName, expectedValue string) { + t.Helper() + + dropdownGroup := doc.AssertDropdown(t, dropdownName) + + selectedValue, _ := dropdownGroup.Find(fmt.Sprintf("input[name='%s']", dropdownName)).Attr("value") + assert.Equal(t, expectedValue, selectedValue, "%s dropdown doesn't have expected value selected", dropdownName) + + dropdownValues := dropdownGroup.Find(".menu [data-value]").Map(func(i int, s *goquery.Selection) string { + value, _ := s.Attr("data-value") + return value + }) + assert.Contains(t, dropdownValues, expectedValue, "%s dropdown doesn't have an option with expected value", dropdownName) +} + +// Find gets the descendants of each element in the current set of +// matched elements, filtered by a selector. It returns a new Selection +// object containing these matched elements. +func (doc *HTMLDoc) Find(selector string) *goquery.Selection { + return doc.doc.Find(selector) +} + +// GetCSRF for getting CSRF token value from input +func (doc *HTMLDoc) GetCSRF() string { + return doc.GetInputValueByName("_csrf") +} + +// AssertElement check if element by selector exists or does not exist depending on checkExists +func (doc *HTMLDoc) AssertElement(t testing.TB, selector string, checkExists bool) { + sel := doc.doc.Find(selector) + if checkExists { + assert.Equal(t, 1, sel.Length()) + } else { + assert.Equal(t, 0, sel.Length()) + } +} diff --git a/tests/integration/incoming_email_test.go b/tests/integration/incoming_email_test.go new file mode 100644 index 0000000..66f833b --- /dev/null +++ b/tests/integration/incoming_email_test.go @@ -0,0 +1,290 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base32" + "io" + "net" + "net/smtp" + "strings" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/mailer/incoming" + incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload" + token_service "code.gitea.io/gitea/services/mailer/token" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/gomail.v2" +) + +func TestIncomingEmail(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + + t.Run("Payload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1}) + + _, err := incoming_payload.CreateReferencePayload(user) + require.Error(t, err) + + issuePayload, err := incoming_payload.CreateReferencePayload(issue) + require.NoError(t, err) + commentPayload, err := incoming_payload.CreateReferencePayload(comment) + require.NoError(t, err) + + _, err = incoming_payload.GetReferenceFromPayload(db.DefaultContext, []byte{1, 2, 3}) + require.Error(t, err) + + ref, err := incoming_payload.GetReferenceFromPayload(db.DefaultContext, issuePayload) + require.NoError(t, err) + assert.IsType(t, ref, new(issues_model.Issue)) + assert.EqualValues(t, issue.ID, ref.(*issues_model.Issue).ID) + + ref, err = incoming_payload.GetReferenceFromPayload(db.DefaultContext, commentPayload) + require.NoError(t, err) + assert.IsType(t, ref, new(issues_model.Comment)) + assert.EqualValues(t, comment.ID, ref.(*issues_model.Comment).ID) + }) + + t.Run("Token", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + payload := []byte{1, 2, 3, 4, 5} + + token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload) + require.NoError(t, err) + assert.NotEmpty(t, token) + + ht, u, p, err := token_service.ExtractToken(db.DefaultContext, token) + require.NoError(t, err) + assert.Equal(t, token_service.ReplyHandlerType, ht) + assert.Equal(t, user.ID, u.ID) + assert.Equal(t, payload, p) + }) + + tokenEncoding := base32.StdEncoding.WithPadding(base32.NoPadding) + t.Run("Deprecated token version", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + payload := []byte{1, 2, 3, 4, 5} + + token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload) + require.NoError(t, err) + assert.NotEmpty(t, token) + + // Set the token to version 1. + unencodedToken, err := tokenEncoding.DecodeString(token) + require.NoError(t, err) + unencodedToken[0] = 1 + token = tokenEncoding.EncodeToString(unencodedToken) + + ht, u, p, err := token_service.ExtractToken(db.DefaultContext, token) + require.ErrorContains(t, err, "unsupported token version: 1") + assert.Equal(t, token_service.UnknownHandlerType, ht) + assert.Nil(t, u) + assert.Nil(t, p) + }) + + t.Run("MAC check", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + payload := []byte{1, 2, 3, 4, 5} + + token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload) + require.NoError(t, err) + assert.NotEmpty(t, token) + + // Modify the MAC. + unencodedToken, err := tokenEncoding.DecodeString(token) + require.NoError(t, err) + unencodedToken[len(unencodedToken)-1] ^= 0x01 + token = tokenEncoding.EncodeToString(unencodedToken) + + ht, u, p, err := token_service.ExtractToken(db.DefaultContext, token) + require.ErrorContains(t, err, "verification failed") + assert.Equal(t, token_service.UnknownHandlerType, ht) + assert.Nil(t, u) + assert.Nil(t, p) + }) + + t.Run("Handler", func(t *testing.T) { + t.Run("Reply", func(t *testing.T) { + checkReply := func(t *testing.T, payload []byte, issue *issues_model.Issue, commentType issues_model.CommentType) { + t.Helper() + + handler := &incoming.ReplyHandler{} + + require.Error(t, handler.Handle(db.DefaultContext, &incoming.MailContent{}, nil, payload)) + require.NoError(t, handler.Handle(db.DefaultContext, &incoming.MailContent{}, user, payload)) + + content := &incoming.MailContent{ + Content: "reply by mail", + Attachments: []*incoming.Attachment{ + { + Name: "attachment.txt", + Content: []byte("test"), + }, + }, + } + + require.NoError(t, handler.Handle(db.DefaultContext, content, user, payload)) + + comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{ + IssueID: issue.ID, + Type: commentType, + }) + require.NoError(t, err) + assert.NotEmpty(t, comments) + comment := comments[len(comments)-1] + assert.Equal(t, user.ID, comment.PosterID) + assert.Equal(t, content.Content, comment.Content) + require.NoError(t, comment.LoadAttachments(db.DefaultContext)) + assert.Len(t, comment.Attachments, 1) + attachment := comment.Attachments[0] + assert.Equal(t, content.Attachments[0].Name, attachment.Name) + assert.EqualValues(t, 4, attachment.Size) + } + t.Run("Issue", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + payload, err := incoming_payload.CreateReferencePayload(issue) + require.NoError(t, err) + + checkReply(t, payload, issue, issues_model.CommentTypeComment) + }) + + t.Run("CodeComment", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 6}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + + payload, err := incoming_payload.CreateReferencePayload(comment) + require.NoError(t, err) + + checkReply(t, payload, issue, issues_model.CommentTypeCode) + }) + + t.Run("Comment", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + + payload, err := incoming_payload.CreateReferencePayload(comment) + require.NoError(t, err) + + checkReply(t, payload, issue, issues_model.CommentTypeComment) + }) + }) + + t.Run("Unsubscribe", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + watching, err := issues_model.CheckIssueWatch(db.DefaultContext, user, issue) + require.NoError(t, err) + assert.True(t, watching) + + handler := &incoming.UnsubscribeHandler{} + + content := &incoming.MailContent{ + Content: "unsub me", + } + + payload, err := incoming_payload.CreateReferencePayload(issue) + require.NoError(t, err) + + require.NoError(t, handler.Handle(db.DefaultContext, content, user, payload)) + + watching, err = issues_model.CheckIssueWatch(db.DefaultContext, user, issue) + require.NoError(t, err) + assert.False(t, watching) + }) + }) + + if setting.IncomingEmail.Enabled { + // This test connects to the configured email server and is currently only enabled for MySql integration tests. + // It sends a reply to create a comment. If the comment is not detected after 10 seconds the test fails. + t.Run("IMAP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + payload, err := incoming_payload.CreateReferencePayload(issue) + require.NoError(t, err) + token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload) + require.NoError(t, err) + + msg := gomail.NewMessage() + msg.SetHeader("To", strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)) + msg.SetHeader("From", user.Email) + msg.SetBody("text/plain", token) + err = gomail.Send(&smtpTestSender{}, msg) + require.NoError(t, err) + + assert.Eventually(t, func() bool { + comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{ + IssueID: issue.ID, + Type: issues_model.CommentTypeComment, + }) + require.NoError(t, err) + assert.NotEmpty(t, comments) + + comment := comments[len(comments)-1] + + return comment.PosterID == user.ID && comment.Content == token + }, 10*time.Second, 1*time.Second) + }) + } +} + +// A simple SMTP mail sender used for integration tests. +type smtpTestSender struct{} + +func (s *smtpTestSender) Send(from string, to []string, msg io.WriterTo) error { + conn, err := net.Dial("tcp", net.JoinHostPort(setting.IncomingEmail.Host, "25")) + if err != nil { + return err + } + defer conn.Close() + + client, err := smtp.NewClient(conn, setting.IncomingEmail.Host) + if err != nil { + return err + } + + if err = client.Mail(from); err != nil { + return err + } + + for _, rec := range to { + if err = client.Rcpt(rec); err != nil { + return err + } + } + + w, err := client.Data() + if err != nil { + return err + } + if _, err := msg.WriteTo(w); err != nil { + return err + } + if err := w.Close(); err != nil { + return err + } + + return client.Quit() +} diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go new file mode 100644 index 0000000..e43200f --- /dev/null +++ b/tests/integration/integration_test.go @@ -0,0 +1,687 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +//nolint:forbidigo +package integration + +import ( + "bytes" + "context" + "fmt" + "hash" + "hash/fnv" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "code.gitea.io/gitea/cmd" + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/testlogger" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/services/auth/source/remote" + gitea_context "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + "github.com/markbates/goth" + "github.com/markbates/goth/gothic" + goth_github "github.com/markbates/goth/providers/github" + goth_gitlab "github.com/markbates/goth/providers/gitlab" + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var testWebRoutes *web.Route + +type NilResponseRecorder struct { + httptest.ResponseRecorder + Length int +} + +func (n *NilResponseRecorder) Write(b []byte) (int, error) { + n.Length += len(b) + return len(b), nil +} + +// NewRecorder returns an initialized ResponseRecorder. +func NewNilResponseRecorder() *NilResponseRecorder { + return &NilResponseRecorder{ + ResponseRecorder: *httptest.NewRecorder(), + } +} + +type NilResponseHashSumRecorder struct { + httptest.ResponseRecorder + Hash hash.Hash + Length int +} + +func (n *NilResponseHashSumRecorder) Write(b []byte) (int, error) { + _, _ = n.Hash.Write(b) + n.Length += len(b) + return len(b), nil +} + +// NewRecorder returns an initialized ResponseRecorder. +func NewNilResponseHashSumRecorder() *NilResponseHashSumRecorder { + return &NilResponseHashSumRecorder{ + Hash: fnv.New32(), + ResponseRecorder: *httptest.NewRecorder(), + } +} + +// runMainApp runs the subcommand and returns its standard output. Any returned error will usually be of type *ExitError. If c.Stderr was nil, Output populates ExitError.Stderr. +func runMainApp(subcommand string, args ...string) (string, error) { + return runMainAppWithStdin(nil, subcommand, args...) +} + +// runMainAppWithStdin runs the subcommand and returns its standard output. Any returned error will usually be of type *ExitError. If c.Stderr was nil, Output populates ExitError.Stderr. +func runMainAppWithStdin(stdin io.Reader, subcommand string, args ...string) (string, error) { + // running the main app directly will very likely mess with the testing setup (logger & co.) + // hence we run it as a subprocess and capture its output + args = append([]string{subcommand}, args...) + cmd := exec.Command(os.Args[0], args...) + cmd.Env = append(os.Environ(), + "GITEA_TEST_CLI=true", + "GITEA_CONF="+setting.CustomConf, + "GITEA_WORK_DIR="+setting.AppWorkPath) + cmd.Stdin = stdin + out, err := cmd.Output() + return string(out), err +} + +func TestMain(m *testing.M) { + // GITEA_TEST_CLI is set by runMainAppWithStdin + // inspired by https://abhinavg.net/2022/05/15/hijack-testmain/ + if testCLI := os.Getenv("GITEA_TEST_CLI"); testCLI == "true" { + app := cmd.NewMainApp("test-version", "integration-test") + args := append([]string{ + "executable-name", // unused, but expected at position 1 + "--config", os.Getenv("GITEA_CONF"), + }, + os.Args[1:]..., // skip the executable name + ) + if err := cmd.RunMainApp(app, args...); err != nil { + panic(err) // should never happen since RunMainApp exits on error + } + return + } + + defer log.GetManager().Close() + + managerCtx, cancel := context.WithCancel(context.Background()) + graceful.InitManager(managerCtx) + defer cancel() + + tests.InitTest(true) + testWebRoutes = routers.NormalRoutes() + + // integration test settings... + if setting.CfgProvider != nil { + testingCfg := setting.CfgProvider.Section("integration-tests") + testlogger.SlowTest = testingCfg.Key("SLOW_TEST").MustDuration(testlogger.SlowTest) + testlogger.SlowFlush = testingCfg.Key("SLOW_FLUSH").MustDuration(testlogger.SlowFlush) + } + + if os.Getenv("GITEA_SLOW_TEST_TIME") != "" { + duration, err := time.ParseDuration(os.Getenv("GITEA_SLOW_TEST_TIME")) + if err == nil { + testlogger.SlowTest = duration + } + } + + if os.Getenv("GITEA_SLOW_FLUSH_TIME") != "" { + duration, err := time.ParseDuration(os.Getenv("GITEA_SLOW_FLUSH_TIME")) + if err == nil { + testlogger.SlowFlush = duration + } + } + + os.Unsetenv("GIT_AUTHOR_NAME") + os.Unsetenv("GIT_AUTHOR_EMAIL") + os.Unsetenv("GIT_AUTHOR_DATE") + os.Unsetenv("GIT_COMMITTER_NAME") + os.Unsetenv("GIT_COMMITTER_EMAIL") + os.Unsetenv("GIT_COMMITTER_DATE") + + err := unittest.InitFixtures( + unittest.FixturesOptions{ + Dir: filepath.Join(filepath.Dir(setting.AppPath), "models/fixtures/"), + }, + ) + if err != nil { + fmt.Printf("Error initializing test database: %v\n", err) + os.Exit(1) + } + + // FIXME: the console logger is deleted by mistake, so if there is any `log.Fatal`, developers won't see any error message. + // Instead, "No tests were found", last nonsense log is "According to the configuration, subsequent logs will not be printed to the console" + exitCode := m.Run() + + if err := testlogger.WriterCloser.Reset(); err != nil { + fmt.Printf("testlogger.WriterCloser.Reset: error ignored: %v\n", err) + } + + if err = util.RemoveAll(setting.Indexer.IssuePath); err != nil { + fmt.Printf("util.RemoveAll: %v\n", err) + os.Exit(1) + } + if err = util.RemoveAll(setting.Indexer.RepoPath); err != nil { + fmt.Printf("Unable to remove repo indexer: %v\n", err) + os.Exit(1) + } + + os.Exit(exitCode) +} + +type TestSession struct { + jar http.CookieJar +} + +func (s *TestSession) GetCookie(name string) *http.Cookie { + baseURL, err := url.Parse(setting.AppURL) + if err != nil { + return nil + } + + for _, c := range s.jar.Cookies(baseURL) { + if c.Name == name { + return c + } + } + return nil +} + +func (s *TestSession) SetCookie(cookie *http.Cookie) *http.Cookie { + baseURL, err := url.Parse(setting.AppURL) + if err != nil { + return nil + } + + s.jar.SetCookies(baseURL, []*http.Cookie{cookie}) + return nil +} + +func (s *TestSession) MakeRequest(t testing.TB, rw *RequestWrapper, expectedStatus int) *httptest.ResponseRecorder { + t.Helper() + req := rw.Request + baseURL, err := url.Parse(setting.AppURL) + require.NoError(t, err) + for _, c := range s.jar.Cookies(baseURL) { + req.AddCookie(c) + } + resp := MakeRequest(t, rw, expectedStatus) + + ch := http.Header{} + ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";")) + cr := http.Request{Header: ch} + s.jar.SetCookies(baseURL, cr.Cookies()) + + return resp +} + +func (s *TestSession) MakeRequestNilResponseRecorder(t testing.TB, rw *RequestWrapper, expectedStatus int) *NilResponseRecorder { + t.Helper() + req := rw.Request + baseURL, err := url.Parse(setting.AppURL) + require.NoError(t, err) + for _, c := range s.jar.Cookies(baseURL) { + req.AddCookie(c) + } + resp := MakeRequestNilResponseRecorder(t, rw, expectedStatus) + + ch := http.Header{} + ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";")) + cr := http.Request{Header: ch} + s.jar.SetCookies(baseURL, cr.Cookies()) + + return resp +} + +func (s *TestSession) MakeRequestNilResponseHashSumRecorder(t testing.TB, rw *RequestWrapper, expectedStatus int) *NilResponseHashSumRecorder { + t.Helper() + req := rw.Request + baseURL, err := url.Parse(setting.AppURL) + require.NoError(t, err) + for _, c := range s.jar.Cookies(baseURL) { + req.AddCookie(c) + } + resp := MakeRequestNilResponseHashSumRecorder(t, rw, expectedStatus) + + ch := http.Header{} + ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";")) + cr := http.Request{Header: ch} + s.jar.SetCookies(baseURL, cr.Cookies()) + + return resp +} + +const userPassword = "password" + +func emptyTestSession(t testing.TB) *TestSession { + t.Helper() + jar, err := cookiejar.New(nil) + require.NoError(t, err) + + return &TestSession{jar: jar} +} + +func getUserToken(t testing.TB, userName string, scope ...auth.AccessTokenScope) string { + return getTokenForLoggedInUser(t, loginUser(t, userName), scope...) +} + +func mockCompleteUserAuth(mock func(res http.ResponseWriter, req *http.Request) (goth.User, error)) func() { + old := gothic.CompleteUserAuth + gothic.CompleteUserAuth = mock + return func() { + gothic.CompleteUserAuth = old + } +} + +func addAuthSource(t *testing.T, payload map[string]string) *auth.Source { + session := loginUser(t, "user1") + payload["_csrf"] = GetCSRF(t, session, "/admin/auths/new") + req := NewRequestWithValues(t, "POST", "/admin/auths/new", payload) + session.MakeRequest(t, req, http.StatusSeeOther) + source, err := auth.GetSourceByName(context.Background(), payload["name"]) + require.NoError(t, err) + return source +} + +func authSourcePayloadOAuth2(name string) map[string]string { + return map[string]string{ + "type": fmt.Sprintf("%d", auth.OAuth2), + "name": name, + "is_active": "on", + } +} + +func authSourcePayloadOpenIDConnect(name, appURL string) map[string]string { + payload := authSourcePayloadOAuth2(name) + payload["oauth2_provider"] = "openidConnect" + payload["open_id_connect_auto_discovery_url"] = appURL + ".well-known/openid-configuration" + return payload +} + +func authSourcePayloadGitLab(name string) map[string]string { + payload := authSourcePayloadOAuth2(name) + payload["oauth2_provider"] = "gitlab" + return payload +} + +func authSourcePayloadGitLabCustom(name string) map[string]string { + payload := authSourcePayloadGitLab(name) + payload["oauth2_use_custom_url"] = "on" + payload["oauth2_auth_url"] = goth_gitlab.AuthURL + payload["oauth2_token_url"] = goth_gitlab.TokenURL + payload["oauth2_profile_url"] = goth_gitlab.ProfileURL + return payload +} + +func authSourcePayloadGitHub(name string) map[string]string { + payload := authSourcePayloadOAuth2(name) + payload["oauth2_provider"] = "github" + return payload +} + +func authSourcePayloadGitHubCustom(name string) map[string]string { + payload := authSourcePayloadGitHub(name) + payload["oauth2_use_custom_url"] = "on" + payload["oauth2_auth_url"] = goth_github.AuthURL + payload["oauth2_token_url"] = goth_github.TokenURL + payload["oauth2_profile_url"] = goth_github.ProfileURL + return payload +} + +func createRemoteAuthSource(t *testing.T, name, url, matchingSource string) *auth.Source { + require.NoError(t, auth.CreateSource(context.Background(), &auth.Source{ + Type: auth.Remote, + Name: name, + IsActive: true, + Cfg: &remote.Source{ + URL: url, + MatchingSource: matchingSource, + }, + })) + source, err := auth.GetSourceByName(context.Background(), name) + require.NoError(t, err) + return source +} + +func createUser(ctx context.Context, t testing.TB, user *user_model.User) func() { + user.MustChangePassword = false + user.LowerName = strings.ToLower(user.Name) + + require.NoError(t, db.Insert(ctx, user)) + + if len(user.Email) > 0 { + require.NoError(t, user_service.ReplacePrimaryEmailAddress(ctx, user, user.Email)) + } + + return func() { + require.NoError(t, user_service.DeleteUser(ctx, user, true)) + } +} + +func loginUser(t testing.TB, userName string) *TestSession { + t.Helper() + + return loginUserWithPassword(t, userName, userPassword) +} + +func loginUserWithPassword(t testing.TB, userName, password string) *TestSession { + t.Helper() + + return loginUserWithPasswordRemember(t, userName, password, false) +} + +func loginUserWithPasswordRemember(t testing.TB, userName, password string, rememberMe bool) *TestSession { + t.Helper() + req := NewRequest(t, "GET", "/user/login") + resp := MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + req = NewRequestWithValues(t, "POST", "/user/login", map[string]string{ + "_csrf": doc.GetCSRF(), + "user_name": userName, + "password": password, + "remember": strconv.FormatBool(rememberMe), + }) + resp = MakeRequest(t, req, http.StatusSeeOther) + + ch := http.Header{} + ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";")) + cr := http.Request{Header: ch} + + session := emptyTestSession(t) + + baseURL, err := url.Parse(setting.AppURL) + require.NoError(t, err) + session.jar.SetCookies(baseURL, cr.Cookies()) + + return session +} + +// token has to be unique this counter take care of +var tokenCounter int64 + +// getTokenForLoggedInUser returns a token for a logged in user. +// The scope is an optional list of snake_case strings like the frontend form fields, +// but without the "scope_" prefix. +func getTokenForLoggedInUser(t testing.TB, session *TestSession, scopes ...auth.AccessTokenScope) string { + t.Helper() + var token string + req := NewRequest(t, "GET", "/user/settings/applications") + resp := session.MakeRequest(t, req, http.StatusOK) + var csrf string + for _, cookie := range resp.Result().Cookies() { + if cookie.Name != "_csrf" { + continue + } + csrf = cookie.Value + break + } + if csrf == "" { + doc := NewHTMLParser(t, resp.Body) + csrf = doc.GetCSRF() + } + assert.NotEmpty(t, csrf) + urlValues := url.Values{} + urlValues.Add("_csrf", csrf) + urlValues.Add("name", fmt.Sprintf("api-testing-token-%d", atomic.AddInt64(&tokenCounter, 1))) + for _, scope := range scopes { + urlValues.Add("scope", string(scope)) + } + req = NewRequestWithURLValues(t, "POST", "/user/settings/applications", urlValues) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + + // Log the flash values on failure + if !assert.Equal(t, []string{"/user/settings/applications"}, resp.Result().Header["Location"]) { + for _, cookie := range resp.Result().Cookies() { + if cookie.Name != gitea_context.CookieNameFlash { + continue + } + flash, _ := url.ParseQuery(cookie.Value) + for key, value := range flash { + t.Logf("Flash %q: %q", key, value) + } + } + } + + req = NewRequest(t, "GET", "/user/settings/applications") + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + token = htmlDoc.doc.Find(".ui.info p").Text() + assert.NotEmpty(t, token) + return token +} + +type RequestWrapper struct { + *http.Request +} + +func (req *RequestWrapper) AddBasicAuth(username string) *RequestWrapper { + req.Request.SetBasicAuth(username, userPassword) + return req +} + +func (req *RequestWrapper) AddTokenAuth(token string) *RequestWrapper { + if token == "" { + return req + } + if !strings.HasPrefix(token, "Bearer ") { + token = "Bearer " + token + } + req.Request.Header.Set("Authorization", token) + return req +} + +func (req *RequestWrapper) SetHeader(name, value string) *RequestWrapper { + req.Request.Header.Set(name, value) + return req +} + +func NewRequest(t testing.TB, method, urlStr string) *RequestWrapper { + t.Helper() + return NewRequestWithBody(t, method, urlStr, nil) +} + +func NewRequestf(t testing.TB, method, urlFormat string, args ...any) *RequestWrapper { + t.Helper() + return NewRequest(t, method, fmt.Sprintf(urlFormat, args...)) +} + +func NewRequestWithValues(t testing.TB, method, urlStr string, values map[string]string) *RequestWrapper { + t.Helper() + urlValues := url.Values{} + for key, value := range values { + urlValues[key] = []string{value} + } + return NewRequestWithURLValues(t, method, urlStr, urlValues) +} + +func NewRequestWithURLValues(t testing.TB, method, urlStr string, urlValues url.Values) *RequestWrapper { + t.Helper() + return NewRequestWithBody(t, method, urlStr, bytes.NewBufferString(urlValues.Encode())). + SetHeader("Content-Type", "application/x-www-form-urlencoded") +} + +func NewRequestWithJSON(t testing.TB, method, urlStr string, v any) *RequestWrapper { + t.Helper() + + jsonBytes, err := json.Marshal(v) + require.NoError(t, err) + return NewRequestWithBody(t, method, urlStr, bytes.NewBuffer(jsonBytes)). + SetHeader("Content-Type", "application/json") +} + +func NewRequestWithBody(t testing.TB, method, urlStr string, body io.Reader) *RequestWrapper { + t.Helper() + if !strings.HasPrefix(urlStr, "http") && !strings.HasPrefix(urlStr, "/") { + urlStr = "/" + urlStr + } + req, err := http.NewRequest(method, urlStr, body) + require.NoError(t, err) + req.RequestURI = urlStr + + return &RequestWrapper{req} +} + +const NoExpectedStatus = -1 + +func MakeRequest(t testing.TB, rw *RequestWrapper, expectedStatus int) *httptest.ResponseRecorder { + t.Helper() + req := rw.Request + recorder := httptest.NewRecorder() + if req.RemoteAddr == "" { + req.RemoteAddr = "test-mock:12345" + } + testWebRoutes.ServeHTTP(recorder, req) + if expectedStatus != NoExpectedStatus { + if !assert.EqualValues(t, expectedStatus, recorder.Code, "Request: %s %s", req.Method, req.URL.String()) { + logUnexpectedResponse(t, recorder) + } + } + return recorder +} + +func MakeRequestNilResponseRecorder(t testing.TB, rw *RequestWrapper, expectedStatus int) *NilResponseRecorder { + t.Helper() + req := rw.Request + recorder := NewNilResponseRecorder() + testWebRoutes.ServeHTTP(recorder, req) + if expectedStatus != NoExpectedStatus { + if !assert.EqualValues(t, expectedStatus, recorder.Code, + "Request: %s %s", req.Method, req.URL.String()) { + logUnexpectedResponse(t, &recorder.ResponseRecorder) + } + } + return recorder +} + +func MakeRequestNilResponseHashSumRecorder(t testing.TB, rw *RequestWrapper, expectedStatus int) *NilResponseHashSumRecorder { + t.Helper() + req := rw.Request + recorder := NewNilResponseHashSumRecorder() + testWebRoutes.ServeHTTP(recorder, req) + if expectedStatus != NoExpectedStatus { + if !assert.EqualValues(t, expectedStatus, recorder.Code, + "Request: %s %s", req.Method, req.URL.String()) { + logUnexpectedResponse(t, &recorder.ResponseRecorder) + } + } + return recorder +} + +// logUnexpectedResponse logs the contents of an unexpected response. +func logUnexpectedResponse(t testing.TB, recorder *httptest.ResponseRecorder) { + t.Helper() + respBytes := recorder.Body.Bytes() + if len(respBytes) == 0 { + // log the content of the flash cookie + for _, cookie := range recorder.Result().Cookies() { + if cookie.Name != gitea_context.CookieNameFlash { + continue + } + flash, _ := url.ParseQuery(cookie.Value) + for key, value := range flash { + // the key is itself url-encoded + if flash, err := url.ParseQuery(key); err == nil { + for key, value := range flash { + t.Logf("FlashCookie %q: %q", key, value) + } + } else { + t.Logf("FlashCookie %q: %q", key, value) + } + } + } + + return + } else if len(respBytes) < 500 { + // if body is short, just log the whole thing + t.Log("Response: ", string(respBytes)) + return + } + t.Log("Response length: ", len(respBytes)) + + // log the "flash" error message, if one exists + // we must create a new buffer, so that we don't "use up" resp.Body + htmlDoc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(respBytes)) + if err != nil { + return // probably a non-HTML response + } + errMsg := htmlDoc.Find(".ui.negative.message").Text() + if len(errMsg) > 0 { + t.Log("A flash error message was found:", errMsg) + } +} + +func DecodeJSON(t testing.TB, resp *httptest.ResponseRecorder, v any) { + t.Helper() + + decoder := json.NewDecoder(resp.Body) + require.NoError(t, decoder.Decode(v)) +} + +func VerifyJSONSchema(t testing.TB, resp *httptest.ResponseRecorder, schemaFile string) { + t.Helper() + + schemaFilePath := filepath.Join(filepath.Dir(setting.AppPath), "tests", "integration", "schemas", schemaFile) + _, schemaFileErr := os.Stat(schemaFilePath) + require.NoError(t, schemaFileErr) + + schema, err := jsonschema.NewCompiler().Compile(schemaFilePath) + require.NoError(t, err) + + var data any + err = json.Unmarshal(resp.Body.Bytes(), &data) + require.NoError(t, err) + + schemaValidation := schema.Validate(data) + require.NoError(t, schemaValidation) +} + +func GetCSRF(t testing.TB, session *TestSession, urlStr string) string { + t.Helper() + req := NewRequest(t, "GET", urlStr) + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + return doc.GetCSRF() +} + +func GetHTMLTitle(t testing.TB, session *TestSession, urlStr string) string { + t.Helper() + + req := NewRequest(t, "GET", urlStr) + var resp *httptest.ResponseRecorder + if session == nil { + resp = MakeRequest(t, req, http.StatusOK) + } else { + resp = session.MakeRequest(t, req, http.StatusOK) + } + + doc := NewHTMLParser(t, resp.Body) + return doc.Find("head title").Text() +} diff --git a/tests/integration/issue_subscribe_test.go b/tests/integration/issue_subscribe_test.go new file mode 100644 index 0000000..32001cd --- /dev/null +++ b/tests/integration/issue_subscribe_test.go @@ -0,0 +1,44 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "path" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIssueSubscribe(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + session := emptyTestSession(t) + testIssueSubscribe(t, *session, true) + }) +} + +func testIssueSubscribe(t *testing.T, session TestSession, unavailable bool) { + t.Helper() + + testIssue := "/user2/repo1/issues/1" + testPull := "/user2/repo1/pulls/2" + selector := ".issue-content-right .watching form" + + resp := session.MakeRequest(t, NewRequest(t, "GET", path.Join(testIssue)), http.StatusOK) + area := NewHTMLParser(t, resp.Body).Find(selector) + tooltip, exists := area.Attr("data-tooltip-content") + assert.EqualValues(t, unavailable, exists) + if unavailable { + assert.EqualValues(t, "Sign in to subscribe to this issue.", tooltip) + } + + resp = session.MakeRequest(t, NewRequest(t, "GET", path.Join(testPull)), http.StatusOK) + area = NewHTMLParser(t, resp.Body).Find(selector) + tooltip, exists = area.Attr("data-tooltip-content") + assert.EqualValues(t, unavailable, exists) + if unavailable { + assert.EqualValues(t, "Sign in to subscribe to this pull request.", tooltip) + } +} diff --git a/tests/integration/issue_test.go b/tests/integration/issue_test.go new file mode 100644 index 0000000..909242e --- /dev/null +++ b/tests/integration/issue_test.go @@ -0,0 +1,1303 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path" + "regexp" + "strconv" + "strings" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + project_model "code.gitea.io/gitea/models/project" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/indexer/issues" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/references" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func getIssuesSelection(t testing.TB, htmlDoc *HTMLDoc) *goquery.Selection { + issueList := htmlDoc.doc.Find("#issue-list") + assert.EqualValues(t, 1, issueList.Length()) + return issueList.Find(".flex-item").Find(".issue-title") +} + +func getIssue(t *testing.T, repoID int64, issueSelection *goquery.Selection) *issues_model.Issue { + href, exists := issueSelection.Attr("href") + assert.True(t, exists) + indexStr := href[strings.LastIndexByte(href, '/')+1:] + index, err := strconv.Atoi(indexStr) + require.NoError(t, err, "Invalid issue href: %s", href) + return unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repoID, Index: int64(index)}) +} + +func assertMatch(t testing.TB, issue *issues_model.Issue, keyword string) { + matches := strings.Contains(strings.ToLower(issue.Title), keyword) || + strings.Contains(strings.ToLower(issue.Content), keyword) + for _, comment := range issue.Comments { + matches = matches || strings.Contains( + strings.ToLower(comment.Content), + keyword, + ) + } + assert.True(t, matches) +} + +func TestNoLoginViewIssues(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues") + MakeRequest(t, req, http.StatusOK) +} + +func TestViewIssues(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues") + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + search := htmlDoc.doc.Find(".list-header-search > .search > .input > input") + placeholder, _ := search.Attr("placeholder") + assert.Equal(t, "Search issues...", placeholder) +} + +func TestViewIssuesSortByType(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + session := loginUser(t, user.Name) + req := NewRequest(t, "GET", repo.Link()+"/issues?type=created_by") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + issuesSelection := getIssuesSelection(t, htmlDoc) + expectedNumIssues := unittest.GetCount(t, + &issues_model.Issue{RepoID: repo.ID, PosterID: user.ID}, + unittest.Cond("is_closed=?", false), + unittest.Cond("is_pull=?", false), + ) + if expectedNumIssues > setting.UI.IssuePagingNum { + expectedNumIssues = setting.UI.IssuePagingNum + } + assert.EqualValues(t, expectedNumIssues, issuesSelection.Length()) + + issuesSelection.Each(func(_ int, selection *goquery.Selection) { + issue := getIssue(t, repo.ID, selection) + assert.EqualValues(t, user.ID, issue.PosterID) + }) +} + +func TestViewIssuesKeyword(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ + RepoID: repo.ID, + Index: 1, + }) + issues.UpdateIssueIndexer(context.Background(), issue.ID) + time.Sleep(time.Second * 1) + + const keyword = "first" + req := NewRequestf(t, "GET", "%s/issues?q=%s", repo.Link(), keyword) + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + issuesSelection := getIssuesSelection(t, htmlDoc) + assert.EqualValues(t, 1, issuesSelection.Length()) + issuesSelection.Each(func(_ int, selection *goquery.Selection) { + issue := getIssue(t, repo.ID, selection) + assert.False(t, issue.IsClosed) + assert.False(t, issue.IsPull) + assertMatch(t, issue, keyword) + }) + + // keyword: 'firstt' + // should not match when fuzzy searching is disabled + req = NewRequestf(t, "GET", "%s/issues?q=%st&fuzzy=false", repo.Link(), keyword) + resp = MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + issuesSelection = getIssuesSelection(t, htmlDoc) + assert.EqualValues(t, 0, issuesSelection.Length()) + + // should match as 'first' when fuzzy seaeching is enabled + for _, fmt := range []string{"%s/issues?q=%st&fuzzy=true", "%s/issues?q=%st"} { + req = NewRequestf(t, "GET", fmt, repo.Link(), keyword) + resp = MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + issuesSelection = getIssuesSelection(t, htmlDoc) + assert.EqualValues(t, 1, issuesSelection.Length()) + issuesSelection.Each(func(_ int, selection *goquery.Selection) { + issue := getIssue(t, repo.ID, selection) + assert.False(t, issue.IsClosed) + assert.False(t, issue.IsPull) + assertMatch(t, issue, keyword) + }) + } +} + +func TestViewIssuesSearchOptions(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + // there are two issues in repo1, both bound to a project. Add one + // that is not bound to any project. + _, issueNoProject := testIssueWithBean(t, "user2", 1, "Title", "Description") + + t.Run("All issues", func(t *testing.T) { + req := NewRequestf(t, "GET", "%s/issues?state=all", repo.Link()) + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + issuesSelection := getIssuesSelection(t, htmlDoc) + assert.EqualValues(t, 3, issuesSelection.Length()) + }) + + t.Run("Issues with no project", func(t *testing.T) { + req := NewRequestf(t, "GET", "%s/issues?state=all&project=-1", repo.Link()) + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + issuesSelection := getIssuesSelection(t, htmlDoc) + assert.EqualValues(t, 1, issuesSelection.Length()) + issuesSelection.Each(func(_ int, selection *goquery.Selection) { + issue := getIssue(t, repo.ID, selection) + assert.Equal(t, issueNoProject.ID, issue.ID) + }) + }) + + t.Run("Issues with a specific project", func(t *testing.T) { + project := unittest.AssertExistsAndLoadBean(t, &project_model.Project{ID: 1}) + + req := NewRequestf(t, "GET", "%s/issues?state=all&project=%d", repo.Link(), project.ID) + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + issuesSelection := getIssuesSelection(t, htmlDoc) + assert.EqualValues(t, 2, issuesSelection.Length()) + found := map[int64]bool{ + 1: false, + 5: false, + } + issuesSelection.Each(func(_ int, selection *goquery.Selection) { + issue := getIssue(t, repo.ID, selection) + found[issue.ID] = true + }) + assert.Len(t, found, 2) + assert.True(t, found[1]) + assert.True(t, found[5]) + }) +} + +func TestNoLoginViewIssue(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues/1") + MakeRequest(t, req, http.StatusOK) +} + +func testNewIssue(t *testing.T, session *TestSession, user, repo, title, content string) string { + req := NewRequest(t, "GET", path.Join(user, repo, "issues", "new")) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action") + assert.True(t, exists, "The template has changed") + req = NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "title": title, + "content": content, + }) + resp = session.MakeRequest(t, req, http.StatusOK) + + issueURL := test.RedirectURL(resp) + req = NewRequest(t, "GET", issueURL) + resp = session.MakeRequest(t, req, http.StatusOK) + + htmlDoc = NewHTMLParser(t, resp.Body) + val := htmlDoc.doc.Find("#issue-title-display").Text() + assert.Contains(t, val, title) + // test for first line only and if it contains only letters and spaces + contentFirstLine := strings.Split(content, "\n")[0] + patNotLetterOrSpace := regexp.MustCompile(`[^\p{L}\s]`) + if len(contentFirstLine) != 0 && !patNotLetterOrSpace.MatchString(contentFirstLine) { + val = htmlDoc.doc.Find(".comment .render-content p").First().Text() + assert.Equal(t, contentFirstLine, val) + } + return issueURL +} + +func testIssueAddComment(t *testing.T, session *TestSession, issueURL, content, status string) int64 { + req := NewRequest(t, "GET", issueURL) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + link, exists := htmlDoc.doc.Find("#comment-form").Attr("action") + assert.True(t, exists, "The template has changed") + + commentCount := htmlDoc.doc.Find(".comment-list .comment .render-content").Length() + + req = NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "content": content, + "status": status, + }) + resp = session.MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", test.RedirectURL(resp)) + resp = session.MakeRequest(t, req, http.StatusOK) + + htmlDoc = NewHTMLParser(t, resp.Body) + + val := htmlDoc.doc.Find(".comment-list .comment .render-content p").Eq(commentCount).Text() + assert.Equal(t, content, val) + + idAttr, has := htmlDoc.doc.Find(".comment-list .comment").Eq(commentCount).Attr("id") + idStr := idAttr[strings.LastIndexByte(idAttr, '-')+1:] + assert.True(t, has) + id, err := strconv.Atoi(idStr) + require.NoError(t, err) + return int64(id) +} + +func TestNewIssue(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + testNewIssue(t, session, "user2", "repo1", "Title", "Description") +} + +func TestIssueCheckboxes(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + issueURL := testNewIssue(t, session, "user2", "repo1", "Title", `- [x] small x +- [X] capital X +- [ ] empty + - [x]x without gap + - [ ]empty without gap +- [x] +x on new line +- [ ] +empty on new line + - [ ] tabs instead of spaces +Description`) + req := NewRequest(t, "GET", issueURL) + resp := session.MakeRequest(t, req, http.StatusOK) + issueContent := NewHTMLParser(t, resp.Body).doc.Find(".comment .render-content").First() + isCheckBox := func(i int, s *goquery.Selection) bool { + typeVal, typeExists := s.Attr("type") + return typeExists && typeVal == "checkbox" + } + isChecked := func(i int, s *goquery.Selection) bool { + _, checkedExists := s.Attr("checked") + return checkedExists + } + checkBoxes := issueContent.Find("input").FilterFunction(isCheckBox) + assert.Equal(t, 8, checkBoxes.Length()) + assert.Equal(t, 4, checkBoxes.FilterFunction(isChecked).Length()) + + // Issues list should show the correct numbers of checked and total checkboxes + repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "repo1") + require.NoError(t, err) + req = NewRequestf(t, "GET", "%s/issues", repo.Link()) + resp = MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + issuesSelection := htmlDoc.Find("#issue-list .flex-item") + assert.Equal(t, "4 / 8", strings.TrimSpace(issuesSelection.Find(".checklist").Text())) + value, _ := issuesSelection.Find("progress").Attr("value") + vmax, _ := issuesSelection.Find("progress").Attr("max") + assert.Equal(t, "4", value) + assert.Equal(t, "8", vmax) +} + +func TestIssueDependencies(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + repo, _, f := tests.CreateDeclarativeRepoWithOptions(t, owner, tests.DeclarativeRepoOptions{}) + defer f() + + createIssue := func(t *testing.T, title string) api.Issue { + t.Helper() + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner.Name, repo.Name) + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{ + Body: "", + Title: title, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + + return apiIssue + } + addDependency := func(t *testing.T, issue, dependency api.Issue) { + t.Helper() + + urlStr := fmt.Sprintf("/%s/%s/issues/%d/dependency/add", owner.Name, repo.Name, issue.Index) + req := NewRequestWithValues(t, "POST", urlStr, map[string]string{ + "_csrf": GetCSRF(t, session, fmt.Sprintf("/%s/%s/issues/%d", owner.Name, repo.Name, issue.Index)), + "newDependency": fmt.Sprintf("%d", dependency.Index), + }) + session.MakeRequest(t, req, http.StatusSeeOther) + } + removeDependency := func(t *testing.T, issue, dependency api.Issue) { + t.Helper() + + urlStr := fmt.Sprintf("/%s/%s/issues/%d/dependency/delete", owner.Name, repo.Name, issue.Index) + req := NewRequestWithValues(t, "POST", urlStr, map[string]string{ + "_csrf": GetCSRF(t, session, fmt.Sprintf("/%s/%s/issues/%d", owner.Name, repo.Name, issue.Index)), + "removeDependencyID": fmt.Sprintf("%d", dependency.Index), + "dependencyType": "blockedBy", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + } + + assertHasDependency := func(t *testing.T, issueID, dependencyID int64, hasDependency bool) { + t.Helper() + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/dependencies", owner.Name, repo.Name, issueID) + req := NewRequest(t, "GET", urlStr) + resp := MakeRequest(t, req, http.StatusOK) + + var issues []api.Issue + DecodeJSON(t, resp, &issues) + + if hasDependency { + assert.NotEmpty(t, issues) + assert.EqualValues(t, issues[0].Index, dependencyID) + } else { + assert.Empty(t, issues) + } + } + + t.Run("Add dependency", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + issue1 := createIssue(t, "issue #1") + issue2 := createIssue(t, "issue #2") + addDependency(t, issue1, issue2) + + assertHasDependency(t, issue1.Index, issue2.Index, true) + }) + + t.Run("Remove dependency", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + issue1 := createIssue(t, "issue #1") + issue2 := createIssue(t, "issue #2") + addDependency(t, issue1, issue2) + removeDependency(t, issue1, issue2) + + assertHasDependency(t, issue1.Index, issue2.Index, false) + }) +} + +func TestEditIssue(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description") + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/content", issueURL), map[string]string{ + "_csrf": GetCSRF(t, session, issueURL), + "content": "modified content", + "context": fmt.Sprintf("/%s/%s", "user2", "repo1"), + }) + session.MakeRequest(t, req, http.StatusOK) + + req = NewRequestWithValues(t, "POST", fmt.Sprintf("%s/content", issueURL), map[string]string{ + "_csrf": GetCSRF(t, session, issueURL), + "content": "modified content", + "context": fmt.Sprintf("/%s/%s", "user2", "repo1"), + }) + session.MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequestWithValues(t, "POST", fmt.Sprintf("%s/content", issueURL), map[string]string{ + "_csrf": GetCSRF(t, session, issueURL), + "content": "modified content", + "content_version": "1", + "context": fmt.Sprintf("/%s/%s", "user2", "repo1"), + }) + session.MakeRequest(t, req, http.StatusOK) +} + +func TestIssueCommentClose(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description") + testIssueAddComment(t, session, issueURL, "Test comment 1", "") + testIssueAddComment(t, session, issueURL, "Test comment 2", "") + testIssueAddComment(t, session, issueURL, "Test comment 3", "close") + + // Validate that issue content has not been updated + req := NewRequest(t, "GET", issueURL) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + val := htmlDoc.doc.Find(".comment-list .comment .render-content p").First().Text() + assert.Equal(t, "Description", val) +} + +func TestIssueCommentDelete(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description") + comment1 := "Test comment 1" + commentID := testIssueAddComment(t, session, issueURL, comment1, "") + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: commentID}) + assert.Equal(t, comment1, comment.Content) + + // Using the ID of a comment that does not belong to the repository must fail + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/comments/%d/delete", "user5", "repo4", commentID), map[string]string{ + "_csrf": GetCSRF(t, session, issueURL), + }) + session.MakeRequest(t, req, http.StatusNotFound) + req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/comments/%d/delete", "user2", "repo1", commentID), map[string]string{ + "_csrf": GetCSRF(t, session, issueURL), + }) + session.MakeRequest(t, req, http.StatusOK) + unittest.AssertNotExistsBean(t, &issues_model.Comment{ID: commentID}) +} + +func TestIssueCommentAttachment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + const repoURL = "user2/repo1" + const content = "Test comment 4" + const status = "" + session := loginUser(t, "user2") + issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description") + + req := NewRequest(t, "GET", issueURL) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + link, exists := htmlDoc.doc.Find("#comment-form").Attr("action") + assert.True(t, exists, "The template has changed") + + uuid := createAttachment(t, session, repoURL, "image.png", generateImg(), http.StatusOK) + + commentCount := htmlDoc.doc.Find(".comment-list .comment .render-content").Length() + + req = NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "content": content, + "status": status, + "files": uuid, + }) + resp = session.MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", test.RedirectURL(resp)) + resp = session.MakeRequest(t, req, http.StatusOK) + + htmlDoc = NewHTMLParser(t, resp.Body) + + val := htmlDoc.doc.Find(".comment-list .comment .render-content p").Eq(commentCount).Text() + assert.Equal(t, content, val) + + idAttr, has := htmlDoc.doc.Find(".comment-list .comment").Eq(commentCount).Attr("id") + idStr := idAttr[strings.LastIndexByte(idAttr, '-')+1:] + assert.True(t, has) + id, err := strconv.Atoi(idStr) + require.NoError(t, err) + assert.NotEqual(t, 0, id) + + req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/comments/%d/attachments", "user2", "repo1", id)) + session.MakeRequest(t, req, http.StatusOK) + + // Using the ID of a comment that does not belong to the repository must fail + req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/comments/%d/attachments", "user5", "repo4", id)) + session.MakeRequest(t, req, http.StatusNotFound) +} + +func TestIssueCommentUpdate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description") + comment1 := "Test comment 1" + commentID := testIssueAddComment(t, session, issueURL, comment1, "") + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: commentID}) + assert.Equal(t, comment1, comment.Content) + + modifiedContent := comment.Content + "MODIFIED" + + // Using the ID of a comment that does not belong to the repository must fail + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/comments/%d", "user5", "repo4", commentID), map[string]string{ + "_csrf": GetCSRF(t, session, issueURL), + "content": modifiedContent, + }) + session.MakeRequest(t, req, http.StatusNotFound) + + req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/comments/%d", "user2", "repo1", commentID), map[string]string{ + "_csrf": GetCSRF(t, session, issueURL), + "content": modifiedContent, + }) + session.MakeRequest(t, req, http.StatusOK) + + comment = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: commentID}) + assert.Equal(t, modifiedContent, comment.Content) + + // make the comment empty + req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/comments/%d", "user2", "repo1", commentID), map[string]string{ + "_csrf": GetCSRF(t, session, issueURL), + "content": "", + "content_version": fmt.Sprintf("%d", comment.ContentVersion), + }) + session.MakeRequest(t, req, http.StatusOK) + + comment = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: commentID}) + assert.Equal(t, "", comment.Content) +} + +func TestIssueCommentUpdateSimultaneously(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description") + comment1 := "Test comment 1" + commentID := testIssueAddComment(t, session, issueURL, comment1, "") + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: commentID}) + assert.Equal(t, comment1, comment.Content) + + modifiedContent := comment.Content + "MODIFIED" + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/comments/%d", "user2", "repo1", commentID), map[string]string{ + "_csrf": GetCSRF(t, session, issueURL), + "content": modifiedContent, + }) + session.MakeRequest(t, req, http.StatusOK) + + modifiedContent = comment.Content + "2" + + req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/comments/%d", "user2", "repo1", commentID), map[string]string{ + "_csrf": GetCSRF(t, session, issueURL), + "content": modifiedContent, + }) + session.MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/comments/%d", "user2", "repo1", commentID), map[string]string{ + "_csrf": GetCSRF(t, session, issueURL), + "content": modifiedContent, + "content_version": "1", + }) + session.MakeRequest(t, req, http.StatusOK) + + comment = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: commentID}) + assert.Equal(t, modifiedContent, comment.Content) + assert.Equal(t, 2, comment.ContentVersion) +} + +func TestIssueReaction(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description") + + req := NewRequest(t, "GET", issueURL) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + req = NewRequestWithValues(t, "POST", path.Join(issueURL, "/reactions/react"), map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "content": "8ball", + }) + session.MakeRequest(t, req, http.StatusInternalServerError) + req = NewRequestWithValues(t, "POST", path.Join(issueURL, "/reactions/react"), map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "content": "eyes", + }) + session.MakeRequest(t, req, http.StatusOK) + req = NewRequestWithValues(t, "POST", path.Join(issueURL, "/reactions/unreact"), map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "content": "eyes", + }) + session.MakeRequest(t, req, http.StatusOK) +} + +func TestIssueCrossReference(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // Issue that will be referenced + _, issueBase := testIssueWithBean(t, "user2", 1, "Title", "Description") + + // Ref from issue title + issueRefURL, issueRef := testIssueWithBean(t, "user2", 1, fmt.Sprintf("Title ref #%d", issueBase.Index), "Description") + unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ + IssueID: issueBase.ID, + RefRepoID: 1, + RefIssueID: issueRef.ID, + RefCommentID: 0, + RefIsPull: false, + RefAction: references.XRefActionNone, + }) + + // Edit title, neuter ref + testIssueChangeInfo(t, "user2", issueRefURL, "title", "Title no ref") + unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ + IssueID: issueBase.ID, + RefRepoID: 1, + RefIssueID: issueRef.ID, + RefCommentID: 0, + RefIsPull: false, + RefAction: references.XRefActionNeutered, + }) + + // Ref from issue content + issueRefURL, issueRef = testIssueWithBean(t, "user2", 1, "TitleXRef", fmt.Sprintf("Description ref #%d", issueBase.Index)) + unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ + IssueID: issueBase.ID, + RefRepoID: 1, + RefIssueID: issueRef.ID, + RefCommentID: 0, + RefIsPull: false, + RefAction: references.XRefActionNone, + }) + + // Edit content, neuter ref + testIssueChangeInfo(t, "user2", issueRefURL, "content", "Description no ref") + unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ + IssueID: issueBase.ID, + RefRepoID: 1, + RefIssueID: issueRef.ID, + RefCommentID: 0, + RefIsPull: false, + RefAction: references.XRefActionNeutered, + }) + + // Ref from a comment + session := loginUser(t, "user2") + commentID := testIssueAddComment(t, session, issueRefURL, fmt.Sprintf("Adding ref from comment #%d", issueBase.Index), "") + comment := &issues_model.Comment{ + IssueID: issueBase.ID, + RefRepoID: 1, + RefIssueID: issueRef.ID, + RefCommentID: commentID, + RefIsPull: false, + RefAction: references.XRefActionNone, + } + unittest.AssertExistsAndLoadBean(t, comment) + + // Ref from a different repository + _, issueRef = testIssueWithBean(t, "user12", 10, "TitleXRef", fmt.Sprintf("Description ref user2/repo1#%d", issueBase.Index)) + unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ + IssueID: issueBase.ID, + RefRepoID: 10, + RefIssueID: issueRef.ID, + RefCommentID: 0, + RefIsPull: false, + RefAction: references.XRefActionNone, + }) +} + +func testIssueWithBean(t *testing.T, user string, repoID int64, title, content string) (string, *issues_model.Issue) { + session := loginUser(t, user) + issueURL := testNewIssue(t, session, user, fmt.Sprintf("repo%d", repoID), title, content) + indexStr := issueURL[strings.LastIndexByte(issueURL, '/')+1:] + index, err := strconv.Atoi(indexStr) + require.NoError(t, err, "Invalid issue href: %s", issueURL) + issue := &issues_model.Issue{RepoID: repoID, Index: int64(index)} + unittest.AssertExistsAndLoadBean(t, issue) + return issueURL, issue +} + +func testIssueChangeInfo(t *testing.T, user, issueURL, info, value string) { + session := loginUser(t, user) + + req := NewRequest(t, "GET", issueURL) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + req = NewRequestWithValues(t, "POST", path.Join(issueURL, info), map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + info: value, + }) + _ = session.MakeRequest(t, req, http.StatusOK) +} + +func TestIssueRedirect(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + + // Test external tracker where style not set (shall default numeric) + req := NewRequest(t, "GET", path.Join("org26", "repo_external_tracker", "issues", "1")) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "https://tracker.com/org26/repo_external_tracker/issues/1", test.RedirectURL(resp)) + + // Test external tracker with numeric style + req = NewRequest(t, "GET", path.Join("org26", "repo_external_tracker_numeric", "issues", "1")) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "https://tracker.com/org26/repo_external_tracker_numeric/issues/1", test.RedirectURL(resp)) + + // Test external tracker with alphanumeric style (for a pull request) + req = NewRequest(t, "GET", path.Join("org26", "repo_external_tracker_alpha", "issues", "1")) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/"+path.Join("org26", "repo_external_tracker_alpha", "pulls", "1"), test.RedirectURL(resp)) +} + +func TestSearchIssues(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + expectedIssueCount := 20 // from the fixtures + if expectedIssueCount > setting.UI.IssuePagingNum { + expectedIssueCount = setting.UI.IssuePagingNum + } + + link, _ := url.Parse("/issues/search") + req := NewRequest(t, "GET", link.String()) + resp := session.MakeRequest(t, req, http.StatusOK) + var apiIssues []*api.Issue + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, expectedIssueCount) + + since := "2000-01-01T00:50:01+00:00" // 946687801 + before := time.Unix(999307200, 0).Format(time.RFC3339) + query := url.Values{} + query.Add("since", since) + query.Add("before", before) + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 11) + query.Del("since") + query.Del("before") + + query.Add("state", "closed") + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 2) + + query.Set("state", "all") + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.EqualValues(t, "22", resp.Header().Get("X-Total-Count")) + assert.Len(t, apiIssues, 20) + + query.Add("limit", "5") + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.EqualValues(t, "22", resp.Header().Get("X-Total-Count")) + assert.Len(t, apiIssues, 5) + + query = url.Values{"assigned": {"true"}, "state": {"all"}} + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 2) + + query = url.Values{"milestones": {"milestone1"}, "state": {"all"}} + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 1) + + query = url.Values{"milestones": {"milestone1,milestone3"}, "state": {"all"}} + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 2) + + query = url.Values{"owner": {"user2"}} // user + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 8) + + query = url.Values{"owner": {"org3"}} // organization + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 5) + + query = url.Values{"owner": {"org3"}, "team": {"team1"}} // organization + team + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 2) +} + +func TestSearchIssuesWithLabels(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + expectedIssueCount := 20 // from the fixtures + if expectedIssueCount > setting.UI.IssuePagingNum { + expectedIssueCount = setting.UI.IssuePagingNum + } + + session := loginUser(t, "user1") + link, _ := url.Parse("/issues/search") + query := url.Values{} + var apiIssues []*api.Issue + + link.RawQuery = query.Encode() + req := NewRequest(t, "GET", link.String()) + resp := session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, expectedIssueCount) + + query.Add("labels", "label1") + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 2) + + // multiple labels + query.Set("labels", "label1,label2") + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 2) + + // an org label + query.Set("labels", "orglabel4") + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 1) + + // org and repo label + query.Set("labels", "label2,orglabel4") + query.Add("state", "all") + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 2) + + // org and repo label which share the same issue + query.Set("labels", "label1,orglabel4") + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 2) +} + +func TestGetIssueInfo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + require.NoError(t, issue.LoadAttributes(db.DefaultContext)) + assert.Equal(t, int64(1019307200), int64(issue.DeadlineUnix)) + assert.Equal(t, api.StateOpen, issue.State()) + + session := loginUser(t, owner.Name) + + urlStr := fmt.Sprintf("/%s/%s/issues/%d/info", owner.Name, repo.Name, issue.Index) + req := NewRequest(t, "GET", urlStr) + resp := session.MakeRequest(t, req, http.StatusOK) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + + assert.EqualValues(t, issue.ID, apiIssue.ID) +} + +func TestIssuePinMove(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + issueURL, issue := testIssueWithBean(t, "user2", 1, "Title", "Content") + assert.EqualValues(t, 0, issue.PinOrder) + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/pin", issueURL), map[string]string{ + "_csrf": GetCSRF(t, session, issueURL), + }) + session.MakeRequest(t, req, http.StatusOK) + issue = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.ID}) + + position := 1 + assert.EqualValues(t, position, issue.PinOrder) + + newPosition := 2 + + // Using the ID of an issue that does not belong to the repository must fail + { + session5 := loginUser(t, "user5") + movePinURL := "/user5/repo4/issues/move_pin?_csrf=" + GetCSRF(t, session5, issueURL) + req = NewRequestWithJSON(t, "POST", movePinURL, map[string]any{ + "id": issue.ID, + "position": newPosition, + }) + session5.MakeRequest(t, req, http.StatusNotFound) + + issue = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.ID}) + assert.EqualValues(t, position, issue.PinOrder) + } + + movePinURL := issueURL[:strings.LastIndexByte(issueURL, '/')] + "/move_pin?_csrf=" + GetCSRF(t, session, issueURL) + req = NewRequestWithJSON(t, "POST", movePinURL, map[string]any{ + "id": issue.ID, + "position": newPosition, + }) + session.MakeRequest(t, req, http.StatusNoContent) + + issue = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.ID}) + assert.EqualValues(t, newPosition, issue.PinOrder) +} + +func TestUpdateIssueDeadline(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10}) + repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID}) + require.NoError(t, issueBefore.LoadAttributes(db.DefaultContext)) + assert.Equal(t, int64(1019307200), int64(issueBefore.DeadlineUnix)) + assert.Equal(t, api.StateOpen, issueBefore.State()) + + session := loginUser(t, owner.Name) + + issueURL := fmt.Sprintf("%s/%s/issues/%d", owner.Name, repoBefore.Name, issueBefore.Index) + req := NewRequest(t, "GET", issueURL) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + urlStr := issueURL + "/deadline?_csrf=" + htmlDoc.GetCSRF() + req = NewRequestWithJSON(t, "POST", urlStr, map[string]string{ + "due_date": "2022-04-06T00:00:00.000Z", + }) + + resp = session.MakeRequest(t, req, http.StatusCreated) + var apiIssue api.IssueDeadline + DecodeJSON(t, resp, &apiIssue) + + assert.EqualValues(t, "2022-04-06", apiIssue.Deadline.Format("2006-01-02")) +} + +func TestUpdateIssueTitle(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + require.NoError(t, issueBefore.LoadAttributes(db.DefaultContext)) + assert.Equal(t, "issue1", issueBefore.Title) + + issueTitleUpdateTests := []struct { + title string + expectedHTTPCode int + }{ + { + title: "normal-title", + expectedHTTPCode: http.StatusOK, + }, + { + title: "extra-long-title-with-exactly-255-chars-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + expectedHTTPCode: http.StatusOK, + }, + { + title: "", + expectedHTTPCode: http.StatusBadRequest, + }, + { + title: " ", + expectedHTTPCode: http.StatusBadRequest, + }, + { + title: "extra-long-title-over-255-chars-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + expectedHTTPCode: http.StatusBadRequest, + }, + } + + session := loginUser(t, owner.Name) + issueURL := fmt.Sprintf("%s/%s/issues/%d", owner.Name, repo.Name, issueBefore.Index) + urlStr := issueURL + "/title" + + for _, issueTitleUpdateTest := range issueTitleUpdateTests { + req := NewRequestWithValues(t, "POST", urlStr, map[string]string{ + "title": issueTitleUpdateTest.title, + "_csrf": GetCSRF(t, session, issueURL), + }) + + resp := session.MakeRequest(t, req, issueTitleUpdateTest.expectedHTTPCode) + + // JSON data is received only if the request succeeds + if issueTitleUpdateTest.expectedHTTPCode == http.StatusOK { + issueAfter := struct { + Title string `json:"title"` + }{} + + DecodeJSON(t, resp, &issueAfter) + assert.EqualValues(t, issueTitleUpdateTest.title, issueAfter.Title) + } + } +} + +func TestIssueReferenceURL(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + + req := NewRequest(t, "GET", fmt.Sprintf("%s/issues/%d", repo.FullName(), issue.Index)) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + // the "reference" uses relative URLs, then JS code will convert them to absolute URLs for current origin, in case users are using multiple domains + ref, _ := htmlDoc.Find(`.timeline-item.comment.first .reference-issue`).Attr("data-reference") + assert.EqualValues(t, "/user2/repo1/issues/1#issue-1", ref) + + ref, _ = htmlDoc.Find(`.timeline-item.comment:not(.first) .reference-issue`).Attr("data-reference") + assert.EqualValues(t, "/user2/repo1/issues/1#issuecomment-2", ref) +} + +func TestGetContentHistory(t *testing.T) { + defer tests.AddFixtures("tests/integration/fixtures/TestGetContentHistory/")() + defer tests.PrepareTestEnv(t)() + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + issueURL := fmt.Sprintf("%s/issues/%d", repo.FullName(), issue.Index) + contentHistory := unittest.AssertExistsAndLoadBean(t, &issues_model.ContentHistory{ID: 2, IssueID: issue.ID}) + contentHistoryURL := fmt.Sprintf("%s/issues/%d/content-history/detail?comment_id=%d&history_id=%d", repo.FullName(), issue.Index, contentHistory.CommentID, contentHistory.ID) + + type contentHistoryResp struct { + CanSoftDelete bool `json:"canSoftDelete"` + HistoryID int `json:"historyId"` + PrevHistoryID int `json:"prevHistoryId"` + } + + testCase := func(t *testing.T, session *TestSession, canDelete bool) { + t.Helper() + contentHistoryURL := contentHistoryURL + "&_csrf=" + GetCSRF(t, session, issueURL) + + req := NewRequest(t, "GET", contentHistoryURL) + resp := session.MakeRequest(t, req, http.StatusOK) + + var respJSON contentHistoryResp + DecodeJSON(t, resp, &respJSON) + + assert.EqualValues(t, canDelete, respJSON.CanSoftDelete) + assert.EqualValues(t, contentHistory.ID, respJSON.HistoryID) + assert.EqualValues(t, contentHistory.ID-1, respJSON.PrevHistoryID) + } + + t.Run("Anonymous", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCase(t, emptyTestSession(t), false) + }) + + t.Run("Another user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCase(t, loginUser(t, "user8"), false) + }) + + t.Run("Repo owner", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCase(t, loginUser(t, "user2"), true) + }) + + t.Run("Poster", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testCase(t, loginUser(t, "user5"), true) + }) +} + +func TestCommitRefComment(t *testing.T) { + defer tests.AddFixtures("tests/integration/fixtures/TestCommitRefComment/")() + defer tests.PrepareTestEnv(t)() + + t.Run("Pull request", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/pulls/2") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + event := htmlDoc.Find("#issuecomment-1000 .text").Text() + assert.Contains(t, event, "referenced this pull request") + }) + + t.Run("Issue", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues/1") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + event := htmlDoc.Find("#issuecomment-1001 .text").Text() + assert.Contains(t, event, "referenced this issue") + }) +} + +func TestIssueFilterNoFollow(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // Check that every link in the filter list has rel="nofollow". + t.Run("Issue lists", func(t *testing.T) { + req := NewRequest(t, "GET", "/user2/repo1/issues") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + filterLinks := htmlDoc.Find(".issue-list-toolbar-right a[href*=\"?q=\"], .labels-list a[href*=\"?q=\"]") + assert.Positive(t, filterLinks.Length()) + filterLinks.Each(func(i int, link *goquery.Selection) { + rel, has := link.Attr("rel") + assert.True(t, has) + assert.Equal(t, "nofollow", rel) + }) + }) + + t.Run("Issue page", func(t *testing.T) { + req := NewRequest(t, "GET", "/user2/repo1/issues/1") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + filterLinks := htmlDoc.Find(".timeline .labels-list a[href*=\"?labels=\"], .issue-content-right .labels-list a[href*=\"?labels=\"]") + assert.Positive(t, filterLinks.Length()) + filterLinks.Each(func(i int, link *goquery.Selection) { + rel, has := link.Attr("rel") + assert.True(t, has) + assert.Equal(t, "nofollow", rel) + }) + }) +} + +func TestIssueForm(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + repo, _, f := tests.CreateDeclarativeRepo(t, user2, "", + []unit_model.Type{unit_model.TypeCode, unit_model.TypeIssues}, nil, + []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".forgejo/issue_template/test.yaml", + ContentReader: strings.NewReader(`name: Test +about: Hello World +body: + - type: checkboxes + id: test + attributes: + label: Test + options: + - label: This is a label +`), + }, + }, + ) + defer f() + + t.Run("Choose list", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", repo.Link()+"/issues/new/choose") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, "a[href$='/issues/new?template=.forgejo%2fissue_template%2ftest.yaml']", true) + }) + + t.Run("Issue template", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", repo.Link()+"/issues/new?template=.forgejo%2fissue_template%2ftest.yaml") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, "#new-issue .field .ui.checkbox input[name='form-field-test-0']", true) + checkboxLabel := htmlDoc.Find("#new-issue .field .ui.checkbox label").Text() + assert.Contains(t, checkboxLabel, "This is a label") + }) + }) +} + +func TestIssueUnsubscription(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + repo, _, f := tests.CreateDeclarativeRepoWithOptions(t, user, tests.DeclarativeRepoOptions{ + AutoInit: optional.Some(false), + }) + defer f() + session := loginUser(t, user.Name) + + issueURL := testNewIssue(t, session, user.Name, repo.Name, "Issue title", "Description") + req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/watch", issueURL), map[string]string{ + "_csrf": GetCSRF(t, session, issueURL), + "watch": "0", + }) + session.MakeRequest(t, req, http.StatusOK) + }) +} + +func TestIssueLabelList(t *testing.T) { + defer tests.PrepareTestEnv(t)() + // The label list should always be present. When no labels are selected, .no-select is visible, otherwise hidden. + labelListSelector := ".labels.list .labels-list" + hiddenClass := "tw-hidden" + + t.Run("Test label list", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues/1") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, labelListSelector, true) + htmlDoc.AssertElement(t, ".labels.list .no-select."+hiddenClass, true) + }) +} + +func TestIssueUserDashboard(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + session := loginUser(t, user.Name) + + // assert 'created_by' is the default filter + const sel = ".dashboard .ui.list-header.dropdown .ui.menu a.active.item[href^='?type=created_by']" + + for _, path := range []string{"/issues", "/pulls"} { + req := NewRequest(t, "GET", path) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, sel, true) + } +} diff --git a/tests/integration/issues_comment_labels_test.go b/tests/integration/issues_comment_labels_test.go new file mode 100644 index 0000000..5299d8a --- /dev/null +++ b/tests/integration/issues_comment_labels_test.go @@ -0,0 +1,197 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "path" + "strings" + "testing" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" +) + +// TestIssuesCommentLabels is a test for user (role) labels in comment headers in PRs and issues. +func TestIssuesCommentLabels(t *testing.T) { + user := "user2" + repo := "repo1" + + ownerTooltip := "This user is the owner of this repository." + authorTooltipPR := "This user is the author of this pull request." + authorTooltipIssue := "This user is the author of this issue." + contributorTooltip := "This user has previously committed in this repository." + newContributorTooltip := "This is the first contribution of this user to the repository." + + // Test pulls + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + sessionUser1 := loginUser(t, "user1") + sessionUser2 := loginUser(t, "user2") + sessionUser11 := loginUser(t, "user11") + + // Open a new PR as user2 + testEditFileToNewBranch(t, sessionUser2, user, repo, "master", "comment-labels", "README.md", "test of comment labels\naline") + sessionUser2.MakeRequest(t, NewRequestWithValues(t, "POST", path.Join(user, repo, "compare", "master...comment-labels"), + map[string]string{ + "_csrf": GetCSRF(t, sessionUser2, path.Join(user, repo, "compare", "master...comment-labels")), + "title": "Pull used for testing commit labels", + }, + ), http.StatusOK) + + // Pull number, expected to be 6 + testID := "6" + + // Add a few comments + // (first: Owner) + testEasyLeavePRReviewComment(t, sessionUser2, user, repo, testID, "README.md", "1", "New review comment from user2 on this line", "") + + // Have to fetch reply ID for reviews + response := sessionUser2.MakeRequest(t, NewRequest(t, "GET", path.Join(user, repo, "pulls", testID)), http.StatusOK) + page := NewHTMLParser(t, response.Body) + replyID, _ := page.Find(".comment-form input[name='reply']").Attr("value") + + testEasyLeavePRReviewComment(t, sessionUser2, user, repo, testID, "README.md", "1", "Another review comment from user2 on this line", replyID) + testEasyLeavePRComment(t, sessionUser2, user, repo, testID, "New comment from user2 on this PR") // Author, Owner + testEasyLeavePRComment(t, sessionUser1, user, repo, testID, "New comment from user1 on this PR") // Contributor + testEasyLeavePRComment(t, sessionUser11, user, repo, testID, "New comment from user11 on this PR") // First-time contributor + + // Fetch the PR page + response = sessionUser2.MakeRequest(t, NewRequest(t, "GET", path.Join(user, repo, "pulls", testID)), http.StatusOK) + page = NewHTMLParser(t, response.Body) + commentHeads := page.Find(".timeline .comment .comment-header .comment-header-right") + assert.EqualValues(t, 6, commentHeads.Length()) + + // Test the first comment and it's label "Owner" + labels := commentHeads.Eq(0).Find(".role-label") + assert.EqualValues(t, 1, labels.Length()) + testIssueCommentUserLabel(t, labels.Eq(0), "Owner", ownerTooltip) + + // Test the second (review) comment and it's labels "Author" and "Owner" + labels = commentHeads.Eq(1).Find(".role-label") + assert.EqualValues(t, 2, labels.Length()) + testIssueCommentUserLabel(t, labels.Eq(0), "Author", authorTooltipPR) + testIssueCommentUserLabel(t, labels.Eq(1), "Owner", ownerTooltip) + + // Test the third (review) comment and it's labels "Author" and "Owner" + labels = commentHeads.Eq(2).Find(".role-label") + assert.EqualValues(t, 2, labels.Length()) + testIssueCommentUserLabel(t, labels.Eq(0), "Author", authorTooltipPR) + testIssueCommentUserLabel(t, labels.Eq(1), "Owner", ownerTooltip) + + // Test the fourth comment and it's labels "Author" and "Owner" + labels = commentHeads.Eq(3).Find(".role-label") + assert.EqualValues(t, 2, labels.Length()) + testIssueCommentUserLabel(t, labels.Eq(0), "Author", authorTooltipPR) + testIssueCommentUserLabel(t, labels.Eq(1), "Owner", ownerTooltip) + + // Test the fivth comment and it's label "Contributor" + labels = commentHeads.Eq(4).Find(".role-label") + assert.EqualValues(t, 1, labels.Length()) + testIssueCommentUserLabel(t, labels.Eq(0), "Contributor", contributorTooltip) + + // Test the sixth comment and it's label "First-time contributor" + labels = commentHeads.Eq(5).Find(".role-label") + assert.EqualValues(t, 1, labels.Length()) + testIssueCommentUserLabel(t, labels.Eq(0), "First-time contributor", newContributorTooltip) + }) + + // Test issues + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + sessionUser1 := loginUser(t, "user1") + sessionUser2 := loginUser(t, "user2") + sessionUser5 := loginUser(t, "user5") + + // Open a new issue in the same repo + sessionUser2.MakeRequest(t, NewRequestWithValues(t, "POST", path.Join(user, repo, "issues/new"), + map[string]string{ + "_csrf": GetCSRF(t, sessionUser2, path.Join(user, repo)), + "title": "Issue used for testing commit labels", + }, + ), http.StatusOK) + + // Issue number, expected to be 6 + testID := "6" + // Add a few comments + // (first: Owner) + testEasyLeaveIssueComment(t, sessionUser2, user, repo, testID, "New comment from user2 on this issue") // Author, Owner + testEasyLeaveIssueComment(t, sessionUser1, user, repo, testID, "New comment from user1 on this issue") // Contributor + testEasyLeaveIssueComment(t, sessionUser5, user, repo, testID, "New comment from user5 on this issue") // no labels + + // Fetch the issue page + response := sessionUser2.MakeRequest(t, NewRequest(t, "GET", path.Join(user, repo, "issues", testID)), http.StatusOK) + page := NewHTMLParser(t, response.Body) + commentHeads := page.Find(".timeline .comment .comment-header .comment-header-right") + assert.EqualValues(t, 4, commentHeads.Length()) + + // Test the first comment and it's label "Owner" + labels := commentHeads.Eq(0).Find(".role-label") + assert.EqualValues(t, 1, labels.Length()) + testIssueCommentUserLabel(t, labels.Eq(0), "Owner", ownerTooltip) + + // Test the second comment and it's labels "Author" and "Owner" + labels = commentHeads.Eq(1).Find(".role-label") + assert.EqualValues(t, 2, labels.Length()) + testIssueCommentUserLabel(t, labels.Eq(0), "Author", authorTooltipIssue) + testIssueCommentUserLabel(t, labels.Eq(1), "Owner", ownerTooltip) + + // Test the third comment and it's label "Contributor" + labels = commentHeads.Eq(2).Find(".role-label") + assert.EqualValues(t, 1, labels.Length()) + testIssueCommentUserLabel(t, labels.Eq(0), "Contributor", contributorTooltip) + + // Test the fifth comment and it's lack of labels + labels = commentHeads.Eq(3).Find(".role-label") + assert.EqualValues(t, 0, labels.Length()) + }) +} + +// testIssueCommentUserLabel is used to verify properties of a user label from a comment +func testIssueCommentUserLabel(t *testing.T, label *goquery.Selection, expectedTitle, expectedTooltip string) { + t.Helper() + title := label.Text() + tooltip, exists := label.Attr("data-tooltip-content") + assert.True(t, exists) + assert.EqualValues(t, expectedTitle, strings.TrimSpace(title)) + assert.EqualValues(t, expectedTooltip, strings.TrimSpace(tooltip)) +} + +// testEasyLeaveIssueComment is used to create a comment on an issue with minimum code and parameters +func testEasyLeaveIssueComment(t *testing.T, session *TestSession, user, repo, id, message string) { + t.Helper() + session.MakeRequest(t, NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", id, "comments"), map[string]string{ + "_csrf": GetCSRF(t, session, path.Join(user, repo, "issues", id)), + "content": message, + "status": "", + }), 200) +} + +// testEasyLeaveIssueComment is used to create a comment on a pull request with minimum code and parameters +// The POST request is supposed to use "issues" in the path. The CSRF is supposed to be generated for the PR page. +func testEasyLeavePRComment(t *testing.T, session *TestSession, user, repo, id, message string) { + t.Helper() + session.MakeRequest(t, NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", id, "comments"), map[string]string{ + "_csrf": GetCSRF(t, session, path.Join(user, repo, "pulls", id)), + "content": message, + "status": "", + }), 200) +} + +// testEasyLeavePRReviewComment is used to add review comments to specific lines of changed files in the diff of the PR. +func testEasyLeavePRReviewComment(t *testing.T, session *TestSession, user, repo, id, file, line, message, replyID string) { + t.Helper() + values := map[string]string{ + "_csrf": GetCSRF(t, session, path.Join(user, repo, "pulls", id, "files")), + "origin": "diff", + "side": "proposed", + "line": line, + "path": file, + "content": message, + "single_review": "true", + } + if len(replyID) > 0 { + values["reply"] = replyID + } + session.MakeRequest(t, NewRequestWithValues(t, "POST", path.Join(user, repo, "pulls", id, "files/reviews/comments"), values), http.StatusOK) +} diff --git a/tests/integration/last_updated_time_test.go b/tests/integration/last_updated_time_test.go new file mode 100644 index 0000000..54c0eeb --- /dev/null +++ b/tests/integration/last_updated_time_test.go @@ -0,0 +1,70 @@ +package integration + +import ( + "net/http" + "net/url" + "path" + "strings" + "testing" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" +) + +func TestRepoLastUpdatedTime(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user := "user2" + session := loginUser(t, user) + + req := NewRequest(t, "GET", "/explore/repos?q=repo1") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + node := doc.doc.Find(".flex-item-body").First() + { + buf := "" + findTextNonNested(t, node, &buf) + assert.Equal(t, "Updated", strings.TrimSpace(buf)) + } + + // Relative time should be present as a descendent + { + relativeTime := node.Find("relative-time").Text() + assert.True(t, strings.HasPrefix(relativeTime, "19")) // ~1970, might underflow with timezone + } + }) +} + +func TestBranchLastUpdatedTime(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user := "user2" + repo := "repo1" + session := loginUser(t, user) + + req := NewRequest(t, "GET", path.Join(user, repo, "branches")) + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + node := doc.doc.Find("p:has(span.commit-message)") + + { + buf := "" + findTextNonNested(t, node, &buf) + assert.True(t, strings.Contains(buf, "Updated")) + } + + { + relativeTime := node.Find("relative-time").Text() + assert.True(t, strings.HasPrefix(relativeTime, "2017")) + } + }) +} + +// Find all text that are direct descendents +func findTextNonNested(t *testing.T, n *goquery.Selection, buf *string) { + t.Helper() + + n.Contents().Each(func(i int, s *goquery.Selection) { + if goquery.NodeName(s) == "#text" { + *buf += s.Text() + } + }) +} diff --git a/tests/integration/lfs_getobject_test.go b/tests/integration/lfs_getobject_test.go new file mode 100644 index 0000000..351c1a3 --- /dev/null +++ b/tests/integration/lfs_getobject_test.go @@ -0,0 +1,228 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/zip" + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/tests" + + "github.com/klauspost/compress/gzhttp" + gzipp "github.com/klauspost/compress/gzip" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func storeObjectInRepo(t *testing.T, repositoryID int64, content *[]byte) string { + pointer, err := lfs.GeneratePointer(bytes.NewReader(*content)) + require.NoError(t, err) + + _, err = git_model.NewLFSMetaObject(db.DefaultContext, repositoryID, pointer) + require.NoError(t, err) + contentStore := lfs.NewContentStore() + exist, err := contentStore.Exists(pointer) + require.NoError(t, err) + if !exist { + err := contentStore.Put(pointer, bytes.NewReader(*content)) + require.NoError(t, err) + } + return pointer.Oid +} + +func storeAndGetLfsToken(t *testing.T, content *[]byte, extraHeader *http.Header, expectedStatus int, ts ...auth.AccessTokenScope) *httptest.ResponseRecorder { + repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "repo1") + require.NoError(t, err) + oid := storeObjectInRepo(t, repo.ID, content) + defer git_model.RemoveLFSMetaObjectByOid(db.DefaultContext, repo.ID, oid) + + token := getUserToken(t, "user2", ts...) + + // Request OID + req := NewRequest(t, "GET", "/user2/repo1.git/info/lfs/objects/"+oid+"/test") + req.Header.Set("Accept-Encoding", "gzip") + req.SetBasicAuth("user2", token) + if extraHeader != nil { + for key, values := range *extraHeader { + for _, value := range values { + req.Header.Add(key, value) + } + } + } + + resp := MakeRequest(t, req, expectedStatus) + + return resp +} + +func storeAndGetLfs(t *testing.T, content *[]byte, extraHeader *http.Header, expectedStatus int) *httptest.ResponseRecorder { + repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "repo1") + require.NoError(t, err) + oid := storeObjectInRepo(t, repo.ID, content) + defer git_model.RemoveLFSMetaObjectByOid(db.DefaultContext, repo.ID, oid) + + session := loginUser(t, "user2") + + // Request OID + req := NewRequest(t, "GET", "/user2/repo1.git/info/lfs/objects/"+oid+"/test") + req.Header.Set("Accept-Encoding", "gzip") + if extraHeader != nil { + for key, values := range *extraHeader { + for _, value := range values { + req.Header.Add(key, value) + } + } + } + + resp := session.MakeRequest(t, req, expectedStatus) + + return resp +} + +func checkResponseTestContentEncoding(t *testing.T, content *[]byte, resp *httptest.ResponseRecorder, expectGzip bool) { + contentEncoding := resp.Header().Get("Content-Encoding") + if !expectGzip || !setting.EnableGzip { + assert.NotContains(t, contentEncoding, "gzip") + + result := resp.Body.Bytes() + assert.Equal(t, *content, result) + } else { + assert.Contains(t, contentEncoding, "gzip") + gzippReader, err := gzipp.NewReader(resp.Body) + require.NoError(t, err) + result, err := io.ReadAll(gzippReader) + require.NoError(t, err) + assert.Equal(t, *content, result) + } +} + +func TestGetLFSSmall(t *testing.T) { + defer tests.PrepareTestEnv(t)() + content := []byte("A very small file\n") + + resp := storeAndGetLfs(t, &content, nil, http.StatusOK) + checkResponseTestContentEncoding(t, &content, resp, false) +} + +func TestGetLFSSmallToken(t *testing.T) { + defer tests.PrepareTestEnv(t)() + content := []byte("A very small file\n") + + resp := storeAndGetLfsToken(t, &content, nil, http.StatusOK, auth.AccessTokenScopePublicOnly, auth.AccessTokenScopeReadRepository) + checkResponseTestContentEncoding(t, &content, resp, false) +} + +func TestGetLFSSmallTokenFail(t *testing.T) { + defer tests.PrepareTestEnv(t)() + content := []byte("A very small file\n") + + storeAndGetLfsToken(t, &content, nil, http.StatusForbidden, auth.AccessTokenScopeReadNotification) +} + +func TestGetLFSLarge(t *testing.T) { + defer tests.PrepareTestEnv(t)() + content := make([]byte, gzhttp.DefaultMinSize*10) + for i := range content { + content[i] = byte(i % 256) + } + + resp := storeAndGetLfs(t, &content, nil, http.StatusOK) + checkResponseTestContentEncoding(t, &content, resp, true) +} + +func TestGetLFSGzip(t *testing.T) { + defer tests.PrepareTestEnv(t)() + b := make([]byte, gzhttp.DefaultMinSize*10) + for i := range b { + b[i] = byte(i % 256) + } + outputBuffer := bytes.NewBuffer([]byte{}) + gzippWriter := gzipp.NewWriter(outputBuffer) + gzippWriter.Write(b) + gzippWriter.Close() + content := outputBuffer.Bytes() + + resp := storeAndGetLfs(t, &content, nil, http.StatusOK) + checkResponseTestContentEncoding(t, &content, resp, false) +} + +func TestGetLFSZip(t *testing.T) { + defer tests.PrepareTestEnv(t)() + b := make([]byte, gzhttp.DefaultMinSize*10) + for i := range b { + b[i] = byte(i % 256) + } + outputBuffer := bytes.NewBuffer([]byte{}) + zipWriter := zip.NewWriter(outputBuffer) + fileWriter, err := zipWriter.Create("default") + require.NoError(t, err) + fileWriter.Write(b) + zipWriter.Close() + content := outputBuffer.Bytes() + + resp := storeAndGetLfs(t, &content, nil, http.StatusOK) + checkResponseTestContentEncoding(t, &content, resp, false) +} + +func TestGetLFSRangeNo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + content := []byte("123456789\n") + + resp := storeAndGetLfs(t, &content, nil, http.StatusOK) + assert.Equal(t, content, resp.Body.Bytes()) +} + +func TestGetLFSRange(t *testing.T) { + defer tests.PrepareTestEnv(t)() + content := []byte("123456789\n") + + tests := []struct { + in string + out string + status int + }{ + {"bytes=0-0", "1", http.StatusPartialContent}, + {"bytes=0-1", "12", http.StatusPartialContent}, + {"bytes=1-1", "2", http.StatusPartialContent}, + {"bytes=1-3", "234", http.StatusPartialContent}, + {"bytes=1-", "23456789\n", http.StatusPartialContent}, + // end-range smaller than start-range is ignored + {"bytes=1-0", "23456789\n", http.StatusPartialContent}, + {"bytes=0-10", "123456789\n", http.StatusPartialContent}, + // end-range bigger than length-1 is ignored + {"bytes=0-11", "123456789\n", http.StatusPartialContent}, + {"bytes=11-", "Requested Range Not Satisfiable", http.StatusRequestedRangeNotSatisfiable}, + // incorrect header value cause whole header to be ignored + {"bytes=-", "123456789\n", http.StatusOK}, + {"foobar", "123456789\n", http.StatusOK}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + h := http.Header{ + "Range": []string{tt.in}, + } + resp := storeAndGetLfs(t, &content, &h, tt.status) + if tt.status == http.StatusPartialContent || tt.status == http.StatusOK { + assert.Equal(t, tt.out, resp.Body.String()) + } else { + var er lfs.ErrorResponse + err := json.Unmarshal(resp.Body.Bytes(), &er) + require.NoError(t, err) + assert.Equal(t, tt.out, er.Message) + } + }) + } +} diff --git a/tests/integration/lfs_local_endpoint_test.go b/tests/integration/lfs_local_endpoint_test.go new file mode 100644 index 0000000..d42888b --- /dev/null +++ b/tests/integration/lfs_local_endpoint_test.go @@ -0,0 +1,113 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "testing" + + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func str2url(raw string) *url.URL { + u, _ := url.Parse(raw) + return u +} + +func TestDetermineLocalEndpoint(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + root := t.TempDir() + + rootdotgit := t.TempDir() + os.Mkdir(filepath.Join(rootdotgit, ".git"), 0o700) + + lfsroot := t.TempDir() + + // Test cases + cases := []struct { + cloneurl string + lfsurl string + expected *url.URL + }{ + // case 0 + { + cloneurl: root, + lfsurl: "", + expected: str2url(fmt.Sprintf("file://%s", root)), + }, + // case 1 + { + cloneurl: root, + lfsurl: lfsroot, + expected: str2url(fmt.Sprintf("file://%s", lfsroot)), + }, + // case 2 + { + cloneurl: "https://git.com/repo.git", + lfsurl: lfsroot, + expected: str2url(fmt.Sprintf("file://%s", lfsroot)), + }, + // case 3 + { + cloneurl: rootdotgit, + lfsurl: "", + expected: str2url(fmt.Sprintf("file://%s", filepath.Join(rootdotgit, ".git"))), + }, + // case 4 + { + cloneurl: "", + lfsurl: rootdotgit, + expected: str2url(fmt.Sprintf("file://%s", filepath.Join(rootdotgit, ".git"))), + }, + // case 5 + { + cloneurl: rootdotgit, + lfsurl: rootdotgit, + expected: str2url(fmt.Sprintf("file://%s", filepath.Join(rootdotgit, ".git"))), + }, + // case 6 + { + cloneurl: fmt.Sprintf("file://%s", root), + lfsurl: "", + expected: str2url(fmt.Sprintf("file://%s", root)), + }, + // case 7 + { + cloneurl: fmt.Sprintf("file://%s", root), + lfsurl: fmt.Sprintf("file://%s", lfsroot), + expected: str2url(fmt.Sprintf("file://%s", lfsroot)), + }, + // case 8 + { + cloneurl: root, + lfsurl: fmt.Sprintf("file://%s", lfsroot), + expected: str2url(fmt.Sprintf("file://%s", lfsroot)), + }, + // case 9 + { + cloneurl: "", + lfsurl: "/does/not/exist", + expected: nil, + }, + // case 10 + { + cloneurl: "", + lfsurl: "file:///does/not/exist", + expected: str2url("file:///does/not/exist"), + }, + } + + for n, c := range cases { + ep := lfs.DetermineEndpoint(c.cloneurl, c.lfsurl) + + assert.Equal(t, c.expected, ep, "case %d: error should match", n) + } +} diff --git a/tests/integration/lfs_view_test.go b/tests/integration/lfs_view_test.go new file mode 100644 index 0000000..06cea0d --- /dev/null +++ b/tests/integration/lfs_view_test.go @@ -0,0 +1,180 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/lfs" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// check that files stored in LFS render properly in the web UI +func TestLFSRender(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + // check that a markup file is flagged with "Stored in Git LFS" and shows its text + t.Run("Markup", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/lfs/src/branch/master/CONTRIBUTING.md") + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body).doc + + fileInfo := doc.Find("div.file-info-entry").First().Text() + assert.Contains(t, fileInfo, "Stored with Git LFS") + + content := doc.Find("div.file-view").Text() + assert.Contains(t, content, "Testing documents in LFS") + }) + + // check that an image is flagged with "Stored in Git LFS" and renders inline + t.Run("Image", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/lfs/src/branch/master/jpeg.jpg") + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body).doc + + fileInfo := doc.Find("div.file-info-entry").First().Text() + assert.Contains(t, fileInfo, "Stored with Git LFS") + + src, exists := doc.Find(".file-view img").Attr("src") + assert.True(t, exists, "The image should be in an <img> tag") + assert.Equal(t, "/user2/lfs/media/branch/master/jpeg.jpg", src, "The image should use the /media link because it's in LFS") + }) + + // check that a binary file is flagged with "Stored in Git LFS" and renders a /media/ link instead of a /raw/ link + t.Run("Binary", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/lfs/src/branch/master/crypt.bin") + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body).doc + + fileInfo := doc.Find("div.file-info-entry").First().Text() + assert.Contains(t, fileInfo, "Stored with Git LFS") + + rawLink, exists := doc.Find("div.file-view > div.view-raw > a").Attr("href") + assert.True(t, exists, "Download link should render instead of content because this is a binary file") + assert.Equal(t, "/user2/lfs/media/branch/master/crypt.bin", rawLink, "The download link should use the proper /media link because it's in LFS") + }) + + // check that a directory with a README file shows its text + t.Run("Readme", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/lfs/src/branch/master/subdir") + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body).doc + + content := doc.Find("div.file-view").Text() + assert.Contains(t, content, "Testing READMEs in LFS") + }) + + t.Run("/settings/lfs/pointers", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // visit /user2/lfs/settings/lfs/pointer + req := NewRequest(t, "GET", "/user2/lfs/settings/lfs/pointers") + resp := session.MakeRequest(t, req, http.StatusOK) + + // follow the first link to /user2/lfs/settings/lfs/find?oid=.... + filesTable := NewHTMLParser(t, resp.Body).doc.Find("#lfs-files-table") + assert.Contains(t, filesTable.Text(), "Find commits") + lfsFind := filesTable.Find(`.primary.button[href^="/user2"]`) + assert.Positive(t, lfsFind.Length()) + lfsFindPath, exists := lfsFind.First().Attr("href") + assert.True(t, exists) + + assert.Contains(t, lfsFindPath, "oid=") + req = NewRequest(t, "GET", lfsFindPath) + resp = session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body).doc + assert.Equal(t, 1, doc.Find(`.sha.label[href="/user2/lfs/commit/73cf03db6ece34e12bf91e8853dc58f678f2f82d"]`).Length(), "could not find link to commit") + }) + + // check that an invalid lfs entry defaults to plaintext + t.Run("Invalid", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/lfs/src/branch/master/invalid") + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body).doc + + content := doc.Find("div.file-view").Text() + assert.Contains(t, content, "oid sha256:9d178b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351") + }) +} + +// TestLFSLockView tests the LFS lock view on settings page of repositories +func TestLFSLockView(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // in org 3 + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // own by org 3 + session := loginUser(t, user2.Name) + + // create a lock + lockPath := "test_lfs_lock_view.zip" + lockID := "" + { + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks", repo3.FullName()), map[string]string{"path": lockPath}) + req.Header.Set("Accept", lfs.AcceptHeader) + req.Header.Set("Content-Type", lfs.MediaType) + resp := session.MakeRequest(t, req, http.StatusCreated) + lockResp := &api.LFSLockResponse{} + DecodeJSON(t, resp, lockResp) + lockID = lockResp.Lock.ID + } + defer func() { + // release the lock + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks/%s/unlock", repo3.FullName(), lockID), map[string]string{}) + req.Header.Set("Accept", lfs.AcceptHeader) + req.Header.Set("Content-Type", lfs.MediaType) + session.MakeRequest(t, req, http.StatusOK) + }() + + t.Run("owner name", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // make sure the display names are different, or the test is meaningless + require.NoError(t, repo3.LoadOwner(context.Background())) + require.NotEqual(t, user2.DisplayName(), repo3.Owner.DisplayName()) + + req := NewRequest(t, "GET", fmt.Sprintf("/%s/settings/lfs/locks", repo3.FullName())) + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body).doc + + tr := doc.Find("table#lfs-files-locks-table tbody tr") + require.Equal(t, 1, tr.Length()) + + td := tr.First().Find("td") + require.Equal(t, 4, td.Length()) + + // path + assert.Equal(t, lockPath, strings.TrimSpace(td.Eq(0).Text())) + // owner name + assert.Equal(t, user2.DisplayName(), strings.TrimSpace(td.Eq(1).Text())) + }) +} diff --git a/tests/integration/linguist_test.go b/tests/integration/linguist_test.go new file mode 100644 index 0000000..73423ee --- /dev/null +++ b/tests/integration/linguist_test.go @@ -0,0 +1,269 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/indexer/stats" + "code.gitea.io/gitea/modules/queue" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLinguistSupport(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + /****************** + ** Preparations ** + ******************/ + prep := func(t *testing.T, attribs string) (*repo_model.Repository, string, func()) { + t.Helper() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + repo, sha, f := tests.CreateDeclarativeRepo(t, user2, "", nil, nil, + []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".gitattributes", + ContentReader: strings.NewReader(attribs), + }, + { + Operation: "create", + TreePath: "docs.md", + ContentReader: strings.NewReader("This **is** a `markdown` file.\n"), + }, + { + Operation: "create", + TreePath: "foo.c", + ContentReader: strings.NewReader("#include <stdio.h>\nint main() {\n printf(\"Hello world!\n\");\n return 0;\n}\n"), + }, + { + Operation: "create", + TreePath: "foo.nib", + ContentReader: strings.NewReader("Pinky promise, this is not a generated file!\n"), + }, + { + Operation: "create", + TreePath: ".dot.pas", + ContentReader: strings.NewReader("program Hello;\nbegin\n writeln('Hello, world.');\nend.\n"), + }, + { + Operation: "create", + TreePath: "cpplint.py", + ContentReader: strings.NewReader("#! /usr/bin/env python\n\nprint(\"Hello world!\")\n"), + }, + { + Operation: "create", + TreePath: "some-file.xml", + ContentReader: strings.NewReader("<?xml version=\"1.0\"?>\n<foo>\n <bar>Hello</bar>\n</foo>\n"), + }, + }) + + return repo, sha, f + } + + getFreshLanguageStats := func(t *testing.T, repo *repo_model.Repository, sha string) repo_model.LanguageStatList { + t.Helper() + + err := stats.UpdateRepoIndexer(repo) + require.NoError(t, err) + + require.NoError(t, queue.GetManager().FlushAll(context.Background(), 10*time.Second)) + + status, err := repo_model.GetIndexerStatus(db.DefaultContext, repo, repo_model.RepoIndexerTypeStats) + require.NoError(t, err) + assert.Equal(t, sha, status.CommitSha) + langs, err := repo_model.GetTopLanguageStats(db.DefaultContext, repo, 5) + require.NoError(t, err) + + return langs + } + + /*********** + ** Tests ** + ***********/ + + // 1. By default, documentation is not indexed + t.Run("default", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo, sha, f := prep(t, "") + defer f() + + langs := getFreshLanguageStats(t, repo, sha) + + // While this is a fairly short test, this exercises a number of + // things: + // + // - `.gitattributes` is empty, so `isDetectable.IsFalse()`, + // `isVendored.IsTrue()`, and `isDocumentation.IsTrue()` will be + // false for every file, because these are only true if an + // attribute is explicitly set. + // + // - There is `.dot.pas`, which would be considered Pascal source, + // but it is a dotfile (thus, `enry.IsDotFile()` applies), and as + // such, is not considered. + // + // - `some-file.xml` will be skipped because Enry considers XML + // configuration, and `enry.IsConfiguration()` will catch it. + // + // - `!isVendored.IsFalse()` evaluates to true, so + // `analyze.isVendor()` will be called on `cpplint.py`, which will + // be considered vendored, even though both the filename and + // contents would otherwise make it Python. + // + // - `!isDocumentation.IsFalse()` evaluates to true, so + // `enry.IsDocumentation()` will be called for `docs.md`, and will + // be considered documentation, thus, skipped. + // + // Thus, this exercises all of the conditions in the first big if + // that is supposed to filter out files early. With two short asserts! + + assert.Len(t, langs, 1) + assert.Equal(t, "C", langs[0].Language) + }) + + // 2. Marking foo.c as non-detectable + t.Run("foo.c non-detectable", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo, sha, f := prep(t, "foo.c linguist-detectable=false\n") + defer f() + + langs := getFreshLanguageStats(t, repo, sha) + assert.Empty(t, langs) + }) + + // 3. Marking Markdown detectable + t.Run("detectable markdown", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo, sha, f := prep(t, "*.md linguist-detectable\n") + defer f() + + langs := getFreshLanguageStats(t, repo, sha) + assert.Len(t, langs, 2) + assert.Equal(t, "C", langs[0].Language) + assert.Equal(t, "Markdown", langs[1].Language) + }) + + // 4. Marking foo.c as documentation + t.Run("foo.c as documentation", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo, sha, f := prep(t, "foo.c linguist-documentation\n") + defer f() + + langs := getFreshLanguageStats(t, repo, sha) + assert.Empty(t, langs) + }) + + // 5. Overriding a generated file + t.Run("linguist-generated=false", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo, sha, f := prep(t, "foo.nib linguist-generated=false\nfoo.nib linguist-language=Perl\n") + defer f() + + langs := getFreshLanguageStats(t, repo, sha) + assert.Len(t, langs, 2) + assert.Equal(t, "C", langs[0].Language) + assert.Equal(t, "Perl", langs[1].Language) + }) + + // 6. Disabling vendoring for a file + t.Run("linguist-vendored=false", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo, sha, f := prep(t, "cpplint.py linguist-vendored=false\n") + defer f() + + langs := getFreshLanguageStats(t, repo, sha) + assert.Len(t, langs, 2) + assert.Equal(t, "C", langs[0].Language) + assert.Equal(t, "Python", langs[1].Language) + }) + + // 7. Disabling vendoring for a file, with -linguist-vendored + t.Run("-linguist-vendored", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo, sha, f := prep(t, "cpplint.py -linguist-vendored\n") + defer f() + + langs := getFreshLanguageStats(t, repo, sha) + assert.Len(t, langs, 2) + assert.Equal(t, "C", langs[0].Language) + assert.Equal(t, "Python", langs[1].Language) + }) + + // 8. Marking foo.c as vendored + t.Run("foo.c as vendored", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo, sha, f := prep(t, "foo.c linguist-vendored\n") + defer f() + + langs := getFreshLanguageStats(t, repo, sha) + assert.Empty(t, langs) + }) + + // 9. Overriding the language + t.Run("linguist-language", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo, _, f := prep(t, "foo.c linguist-language=sh\n") + defer f() + + assertFileLanguage := func(t *testing.T, uri, expectedLanguage string) { + t.Helper() + + req := NewRequest(t, "GET", repo.Link()+uri) + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + language := strings.TrimSpace(htmlDoc.Find(".file-info .file-info-entry:nth-child(3)").Text()) + assert.Equal(t, expectedLanguage, language) + } + + t.Run("file source view", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assertFileLanguage(t, "/src/branch/main/foo.c?display=source", "Bash") + }) + + t.Run("file blame view", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assertFileLanguage(t, "/blame/branch/main/foo.c", "Bash") + }) + }) + + // 10. Marking a file as non-documentation + t.Run("linguist-documentation=false", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo, sha, f := prep(t, "README.md linguist-documentation=false\n") + defer f() + + langs := getFreshLanguageStats(t, repo, sha) + assert.Len(t, langs, 2) + assert.Equal(t, "Markdown", langs[0].Language) + assert.Equal(t, "C", langs[1].Language) + }) + }) +} diff --git a/tests/integration/links_test.go b/tests/integration/links_test.go new file mode 100644 index 0000000..e9ad933 --- /dev/null +++ b/tests/integration/links_test.go @@ -0,0 +1,251 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "path" + "testing" + + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + forgejo_context "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestLinksNoLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + links := []string{ + "/explore/repos", + "/explore/repos?q=test", + "/explore/users", + "/explore/users?q=test", + "/explore/organizations", + "/explore/organizations?q=test", + "/", + "/user/sign_up", + "/user/login", + "/user/forgot_password", + "/api/swagger", + "/user2/repo1", + "/user2/repo1/", + "/user2/repo1/projects", + "/user2/repo1/projects/1", + "/.well-known/security.txt", + } + + for _, link := range links { + req := NewRequest(t, "GET", link) + MakeRequest(t, req, http.StatusOK) + } +} + +func TestRedirectsNoLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + redirects := map[string]string{ + "/user2/repo1/commits/master": "/user2/repo1/commits/branch/master", + "/user2/repo1/src/master": "/user2/repo1/src/branch/master", + "/user2/repo1/src/master/file.txt": "/user2/repo1/src/branch/master/file.txt", + "/user2/repo1/src/master/directory/file.txt": "/user2/repo1/src/branch/master/directory/file.txt", + "/user/avatar/Ghost/-1": "/assets/img/avatar_default.png", + "/api/v1/swagger": "/api/swagger", + } + for link, redirectLink := range redirects { + req := NewRequest(t, "GET", link) + resp := MakeRequest(t, req, http.StatusSeeOther) + assert.EqualValues(t, path.Join(setting.AppSubURL, redirectLink), test.RedirectURL(resp)) + } +} + +func TestNoLoginNotExist(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + links := []string{ + "/user5/repo4/projects", + "/user5/repo4/projects/3", + } + + for _, link := range links { + req := NewRequest(t, "GET", link) + MakeRequest(t, req, http.StatusNotFound) + } +} + +func testLinksAsUser(userName string, t *testing.T) { + links := []string{ + "/explore/repos", + "/explore/repos?q=test", + "/explore/users", + "/explore/users?q=test", + "/explore/organizations", + "/explore/organizations?q=test", + "/", + "/user/forgot_password", + "/api/swagger", + "/issues", + "/issues?type=your_repositories&repos=[0]&sort=&state=open", + "/issues?type=assigned&repos=[0]&sort=&state=open", + "/issues?type=your_repositories&repos=[0]&sort=&state=closed", + "/issues?type=assigned&repos=[]&sort=&state=closed", + "/issues?type=assigned&sort=&state=open", + "/issues?type=created_by&repos=[1,2]&sort=&state=closed", + "/issues?type=created_by&repos=[1,2]&sort=&state=open", + "/pulls", + "/pulls?type=your_repositories&repos=[2]&sort=&state=open", + "/pulls?type=assigned&repos=[]&sort=&state=open", + "/pulls?type=created_by&repos=[0]&sort=&state=open", + "/pulls?type=your_repositories&repos=[0]&sort=&state=closed", + "/pulls?type=assigned&repos=[0]&sort=&state=closed", + "/pulls?type=created_by&repos=[0]&sort=&state=closed", + "/milestones", + "/milestones?sort=mostcomplete&state=closed", + "/milestones?type=your_repositories&sort=mostcomplete&state=closed", + "/milestones?sort=&repos=[1]&state=closed", + "/milestones?sort=&repos=[1]&state=open", + "/milestones?repos=[0]&sort=mostissues&state=open", + "/notifications", + "/repo/create", + "/repo/migrate", + "/org/create", + "/user2", + "/user2?tab=stars", + "/user2?tab=activity", + "/user/settings", + "/user/settings/account", + "/user/settings/security", + "/user/settings/security/two_factor/enroll", + "/user/settings/keys", + "/user/settings/organization", + "/user/settings/repos", + } + + session := loginUser(t, userName) + for _, link := range links { + req := NewRequest(t, "GET", link) + session.MakeRequest(t, req, http.StatusOK) + } + + reqAPI := NewRequestf(t, "GET", "/api/v1/users/%s/repos", userName) + respAPI := MakeRequest(t, reqAPI, http.StatusOK) + + var apiRepos []*api.Repository + DecodeJSON(t, respAPI, &apiRepos) + + repoLinks := []string{ + "", + "/issues", + "/pulls", + "/commits/branch/master", + "/graph", + "/settings", + "/settings/collaboration", + "/settings/branches", + "/settings/hooks", + // FIXME: below links should return 200 but 404 ?? + //"/settings/hooks/git", + //"/settings/hooks/git/pre-receive", + //"/settings/hooks/git/update", + //"/settings/hooks/git/post-receive", + "/settings/keys", + "/releases", + "/releases/new", + //"/wiki/_pages", + "/wiki/?action=_new", + "/activity", + } + + for _, repo := range apiRepos { + for _, link := range repoLinks { + req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s%s", userName, repo.Name, link)) + session.MakeRequest(t, req, http.StatusOK) + } + } +} + +func TestLinksLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + testLinksAsUser("user2", t) +} + +func TestRedirectsWebhooks(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // + // A redirect means the route exists but not if it performs as intended. + // + for _, kind := range []string{"forgejo", "gitea"} { + redirects := []struct { + from string + to string + verb string + }{ + {from: "/user2/repo1/settings/hooks/" + kind + "/new", to: "/user/login", verb: "GET"}, + {from: "/user/settings/hooks/" + kind + "/new", to: "/user/login", verb: "GET"}, + {from: "/admin/system-hooks/" + kind + "/new", to: "/user/login", verb: "GET"}, + {from: "/admin/default-hooks/" + kind + "/new", to: "/user/login", verb: "GET"}, + } + for _, info := range redirects { + req := NewRequest(t, info.verb, info.from) + resp := MakeRequest(t, req, http.StatusSeeOther) + assert.EqualValues(t, path.Join(setting.AppSubURL, info.to), test.RedirectURL(resp), info.from) + } + } + + for _, kind := range []string{"forgejo", "gitea"} { + csrf := []struct { + from string + verb string + }{ + {from: "/user2/repo1/settings/hooks/" + kind + "/new", verb: "POST"}, + {from: "/admin/hooks/1", verb: "POST"}, + {from: "/admin/system-hooks/" + kind + "/new", verb: "POST"}, + {from: "/admin/default-hooks/" + kind + "/new", verb: "POST"}, + {from: "/user2/repo1/settings/hooks/1", verb: "POST"}, + } + for _, info := range csrf { + req := NewRequest(t, info.verb, info.from) + resp := MakeRequest(t, req, http.StatusBadRequest) + assert.Contains(t, resp.Body.String(), forgejo_context.CsrfErrorString) + } + } +} + +func TestRepoLinks(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // repo1 has enabled almost features, so we can test most links + repoLink := "/user2/repo1" + links := []string{ + "/actions", + "/packages", + "/projects", + } + + // anonymous user + for _, link := range links { + req := NewRequest(t, "GET", repoLink+link) + MakeRequest(t, req, http.StatusOK) + } + + // admin/owner user + session := loginUser(t, "user1") + for _, link := range links { + req := NewRequest(t, "GET", repoLink+link) + session.MakeRequest(t, req, http.StatusOK) + } + + // non-admin non-owner user + session = loginUser(t, "user2") + for _, link := range links { + req := NewRequest(t, "GET", repoLink+link) + session.MakeRequest(t, req, http.StatusOK) + } +} diff --git a/tests/integration/markup_external_test.go b/tests/integration/markup_external_test.go new file mode 100644 index 0000000..0eaa966 --- /dev/null +++ b/tests/integration/markup_external_test.go @@ -0,0 +1,40 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "io" + "net/http" + "strings" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExternalMarkupRenderer(t *testing.T) { + defer tests.PrepareTestEnv(t)() + if !setting.Database.Type.IsSQLite3() { + t.Skip() + return + } + + const repoURL = "user30/renderer" + req := NewRequest(t, "GET", repoURL+"/src/branch/master/README.html") + resp := MakeRequest(t, req, http.StatusOK) + assert.EqualValues(t, "text/html; charset=utf-8", resp.Header()["Content-Type"][0]) + + bs, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + doc := NewHTMLParser(t, bytes.NewBuffer(bs)) + div := doc.Find("div.file-view") + data, err := div.Html() + require.NoError(t, err) + assert.EqualValues(t, "<div>\n\ttest external renderer\n</div>", strings.TrimSpace(data)) +} diff --git a/tests/integration/markup_test.go b/tests/integration/markup_test.go new file mode 100644 index 0000000..d63190a --- /dev/null +++ b/tests/integration/markup_test.go @@ -0,0 +1,72 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestRenderAlertBlocks(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteMisc) + + assertAlertBlock := func(t *testing.T, input, alertType, alertIcon string) { + t.Helper() + + blockquoteAttr := fmt.Sprintf(`<blockquote class="attention-header attention-%s"`, strings.ToLower(alertType)) + classAttr := fmt.Sprintf(`class="attention-%s"`, strings.ToLower(alertType)) + iconAttr := fmt.Sprintf(`svg octicon-%s`, alertIcon) + + req := NewRequestWithJSON(t, "POST", "/api/v1/markdown", &api.MarkdownOption{ + Text: input, + Mode: "markdown", + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + body := resp.Body.String() + assert.Contains(t, body, blockquoteAttr) + assert.Contains(t, body, classAttr) + assert.Contains(t, body, iconAttr) + } + + t.Run("legacy style", func(t *testing.T) { + for alertType, alertIcon := range map[string]string{"Note": "info", "Warning": "alert"} { + t.Run(alertType, func(t *testing.T) { + input := fmt.Sprintf(`> **%s** +> +> This is a %s.`, alertType, alertType) + + assertAlertBlock(t, input, alertType, alertIcon) + }) + } + }) + + t.Run("modern style", func(t *testing.T) { + for alertType, alertIcon := range map[string]string{ + "NOTE": "info", + "TIP": "light-bulb", + "IMPORTANT": "report", + "WARNING": "alert", + "CAUTION": "stop", + } { + t.Run(alertType, func(t *testing.T) { + input := fmt.Sprintf(`> [!%s] +> +> This is a %s.`, alertType, alertType) + + assertAlertBlock(t, input, alertType, alertIcon) + }) + } + }) +} diff --git a/tests/integration/migrate_test.go b/tests/integration/migrate_test.go new file mode 100644 index 0000000..43cfc4f --- /dev/null +++ b/tests/integration/migrate_test.go @@ -0,0 +1,130 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/services/migrations" + "code.gitea.io/gitea/services/repository" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMigrateLocalPath(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"}) + + old := setting.ImportLocalPaths + setting.ImportLocalPaths = true + + basePath := t.TempDir() + + lowercasePath := filepath.Join(basePath, "lowercase") + err := os.Mkdir(lowercasePath, 0o700) + require.NoError(t, err) + + err = migrations.IsMigrateURLAllowed(lowercasePath, adminUser) + require.NoError(t, err, "case lowercase path") + + mixedcasePath := filepath.Join(basePath, "mIxeDCaSe") + err = os.Mkdir(mixedcasePath, 0o700) + require.NoError(t, err) + + err = migrations.IsMigrateURLAllowed(mixedcasePath, adminUser) + require.NoError(t, err, "case mixedcase path") + + setting.ImportLocalPaths = old +} + +func TestMigrate(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + AllowLocalNetworks := setting.Migrations.AllowLocalNetworks + setting.Migrations.AllowLocalNetworks = true + AppVer := setting.AppVer + // Gitea SDK (go-sdk) need to parse the AppVer from server response, so we must set it to a valid version string. + setting.AppVer = "1.16.0" + defer func() { + setting.Migrations.AllowLocalNetworks = AllowLocalNetworks + setting.AppVer = AppVer + migrations.Init() + }() + require.NoError(t, migrations.Init()) + + ownerName := "user2" + repoName := "repo1" + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: ownerName}) + session := loginUser(t, ownerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeReadMisc) + + for _, s := range []struct { + svc structs.GitServiceType + }{ + {svc: structs.GiteaService}, + {svc: structs.ForgejoService}, + } { + // Step 0: verify the repo is available + req := NewRequestf(t, "GET", "/%s/%s", ownerName, repoName) + _ = session.MakeRequest(t, req, http.StatusOK) + // Step 1: get the Gitea migration form + req = NewRequestf(t, "GET", "/repo/migrate/?service_type=%d", s.svc) + resp := session.MakeRequest(t, req, http.StatusOK) + // Step 2: load the form + htmlDoc := NewHTMLParser(t, resp.Body) + // Check form title + title := htmlDoc.doc.Find("title").Text() + assert.Contains(t, title, translation.NewLocale("en-US").TrString("new_migrate.title")) + // Get the link of migration button + link, exists := htmlDoc.doc.Find(`form.ui.form[action^="/repo/migrate"]`).Attr("action") + assert.True(t, exists, "The template has changed") + // Step 4: submit the migration to only migrate issues + migratedRepoName := "otherrepo" + req = NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "service": fmt.Sprintf("%d", s.svc), + "clone_addr": fmt.Sprintf("%s%s/%s", u, ownerName, repoName), + "auth_token": token, + "issues": "on", + "repo_name": migratedRepoName, + "description": "", + "uid": fmt.Sprintf("%d", repoOwner.ID), + }) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + // Step 5: a redirection displays the migrated repository + loc := resp.Header().Get("Location") + assert.EqualValues(t, fmt.Sprintf("/%s/%s", ownerName, migratedRepoName), loc) + // Step 6: check the repo was created + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: migratedRepoName}) + + // Step 7: delete the repository, so we can test with other services + err := repository.DeleteRepository(context.Background(), repoOwner, repo, false) + require.NoError(t, err) + } + }) +} + +func Test_UpdateCommentsMigrationsByType(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + err := issues_model.UpdateCommentsMigrationsByType(db.DefaultContext, structs.GithubService, "1", 1) + require.NoError(t, err) +} diff --git a/tests/integration/migration-test/forgejo-v1.19.0.mysql.sql.gz b/tests/integration/migration-test/forgejo-v1.19.0.mysql.sql.gz Binary files differnew file mode 100644 index 0000000..4cea13b --- /dev/null +++ b/tests/integration/migration-test/forgejo-v1.19.0.mysql.sql.gz diff --git a/tests/integration/migration-test/forgejo-v1.19.0.postgres.sql.gz b/tests/integration/migration-test/forgejo-v1.19.0.postgres.sql.gz Binary files differnew file mode 100644 index 0000000..7fdc409 --- /dev/null +++ b/tests/integration/migration-test/forgejo-v1.19.0.postgres.sql.gz diff --git a/tests/integration/migration-test/forgejo-v1.19.0.sqlite3.sql.gz b/tests/integration/migration-test/forgejo-v1.19.0.sqlite3.sql.gz Binary files differnew file mode 100644 index 0000000..dba4baf --- /dev/null +++ b/tests/integration/migration-test/forgejo-v1.19.0.sqlite3.sql.gz diff --git a/tests/integration/migration-test/gitea-v1.6.4.mysql.sql.gz b/tests/integration/migration-test/gitea-v1.6.4.mysql.sql.gz Binary files differnew file mode 100644 index 0000000..30cca8b --- /dev/null +++ b/tests/integration/migration-test/gitea-v1.6.4.mysql.sql.gz diff --git a/tests/integration/migration-test/gitea-v1.6.4.postgres.sql.gz b/tests/integration/migration-test/gitea-v1.6.4.postgres.sql.gz Binary files differnew file mode 100644 index 0000000..bd66f6b --- /dev/null +++ b/tests/integration/migration-test/gitea-v1.6.4.postgres.sql.gz diff --git a/tests/integration/migration-test/gitea-v1.6.4.sqlite3.sql.gz b/tests/integration/migration-test/gitea-v1.6.4.sqlite3.sql.gz Binary files differnew file mode 100644 index 0000000..a777c53 --- /dev/null +++ b/tests/integration/migration-test/gitea-v1.6.4.sqlite3.sql.gz diff --git a/tests/integration/migration-test/gitea-v1.7.0.mysql.sql.gz b/tests/integration/migration-test/gitea-v1.7.0.mysql.sql.gz Binary files differnew file mode 100644 index 0000000..d0ab108 --- /dev/null +++ b/tests/integration/migration-test/gitea-v1.7.0.mysql.sql.gz diff --git a/tests/integration/migration-test/gitea-v1.7.0.postgres.sql.gz b/tests/integration/migration-test/gitea-v1.7.0.postgres.sql.gz Binary files differnew file mode 100644 index 0000000..e4716c6 --- /dev/null +++ b/tests/integration/migration-test/gitea-v1.7.0.postgres.sql.gz diff --git a/tests/integration/migration-test/gitea-v1.7.0.sqlite3.sql.gz b/tests/integration/migration-test/gitea-v1.7.0.sqlite3.sql.gz Binary files differnew file mode 100644 index 0000000..3155249 --- /dev/null +++ b/tests/integration/migration-test/gitea-v1.7.0.sqlite3.sql.gz diff --git a/tests/integration/migration-test/migration_test.go b/tests/integration/migration-test/migration_test.go new file mode 100644 index 0000000..a391296 --- /dev/null +++ b/tests/integration/migration-test/migration_test.go @@ -0,0 +1,323 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package migrations + +import ( + "compress/gzip" + "context" + "database/sql" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/migrations" + migrate_base "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/testlogger" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "xorm.io/xorm" +) + +var currentEngine *xorm.Engine + +func initMigrationTest(t *testing.T) func() { + log.RegisterEventWriter("test", testlogger.NewTestLoggerWriter) + + deferFn := tests.PrintCurrentTest(t, 2) + giteaRoot := base.SetupGiteaRoot() + if giteaRoot == "" { + tests.Printf("Environment variable $GITEA_ROOT not set\n") + os.Exit(1) + } + setting.AppPath = path.Join(giteaRoot, "gitea") + if _, err := os.Stat(setting.AppPath); err != nil { + tests.Printf("Could not find gitea binary at %s\n", setting.AppPath) + os.Exit(1) + } + + giteaConf := os.Getenv("GITEA_CONF") + if giteaConf == "" { + tests.Printf("Environment variable $GITEA_CONF not set\n") + os.Exit(1) + } else if !path.IsAbs(giteaConf) { + setting.CustomConf = path.Join(giteaRoot, giteaConf) + } else { + setting.CustomConf = giteaConf + } + + unittest.InitSettings() + + assert.NotEmpty(t, setting.RepoRootPath) + require.NoError(t, util.RemoveAll(setting.RepoRootPath)) + require.NoError(t, unittest.CopyDir(path.Join(filepath.Dir(setting.AppPath), "tests/gitea-repositories-meta"), setting.RepoRootPath)) + ownerDirs, err := os.ReadDir(setting.RepoRootPath) + if err != nil { + require.NoError(t, err, "unable to read the new repo root: %v\n", err) + } + for _, ownerDir := range ownerDirs { + if !ownerDir.Type().IsDir() { + continue + } + repoDirs, err := os.ReadDir(filepath.Join(setting.RepoRootPath, ownerDir.Name())) + if err != nil { + require.NoError(t, err, "unable to read the new repo root: %v\n", err) + } + for _, repoDir := range repoDirs { + _ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "objects", "pack"), 0o755) + _ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "objects", "info"), 0o755) + _ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "refs", "heads"), 0o755) + _ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "refs", "tag"), 0o755) + } + } + + require.NoError(t, git.InitFull(context.Background())) + setting.LoadDBSetting() + setting.InitLoggersForTest() + return deferFn +} + +func availableVersions() ([]string, error) { + migrationsDir, err := os.Open("tests/integration/migration-test") + if err != nil { + return nil, err + } + defer migrationsDir.Close() + versionRE, err := regexp.Compile(".*-v(?P<version>.+)\\." + regexp.QuoteMeta(setting.Database.Type.String()) + "\\.sql.gz") + if err != nil { + return nil, err + } + + filenames, err := migrationsDir.Readdirnames(-1) + if err != nil { + return nil, err + } + versions := []string{} + for _, filename := range filenames { + if versionRE.MatchString(filename) { + substrings := versionRE.FindStringSubmatch(filename) + versions = append(versions, substrings[1]) + } + } + sort.Strings(versions) + return versions, nil +} + +func readSQLFromFile(version string) (string, error) { + filename := fmt.Sprintf("tests/integration/migration-test/gitea-v%s.%s.sql.gz", version, setting.Database.Type) + + if _, err := os.Stat(filename); os.IsNotExist(err) { + filename = fmt.Sprintf("tests/integration/migration-test/forgejo-v%s.%s.sql.gz", version, setting.Database.Type) + if _, err := os.Stat(filename); os.IsNotExist(err) { + return "", nil + } + } + + file, err := os.Open(filename) + if err != nil { + return "", err + } + defer file.Close() + + gr, err := gzip.NewReader(file) + if err != nil { + return "", err + } + defer gr.Close() + + bytes, err := io.ReadAll(gr) + if err != nil { + return "", err + } + return string(charset.MaybeRemoveBOM(bytes, charset.ConvertOpts{})), nil +} + +func restoreOldDB(t *testing.T, version string) bool { + data, err := readSQLFromFile(version) + require.NoError(t, err) + if len(data) == 0 { + tests.Printf("No db found to restore for %s version: %s\n", setting.Database.Type, version) + return false + } + + switch { + case setting.Database.Type.IsSQLite3(): + util.Remove(setting.Database.Path) + err := os.MkdirAll(path.Dir(setting.Database.Path), os.ModePerm) + require.NoError(t, err) + + db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?cache=shared&mode=rwc&_busy_timeout=%d&_txlock=immediate", setting.Database.Path, setting.Database.Timeout)) + require.NoError(t, err) + defer db.Close() + + _, err = db.Exec(data) + require.NoError(t, err) + db.Close() + + case setting.Database.Type.IsMySQL(): + db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/", + setting.Database.User, setting.Database.Passwd, setting.Database.Host)) + require.NoError(t, err) + defer db.Close() + + databaseName := strings.SplitN(setting.Database.Name, "?", 2)[0] + + _, err = db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", databaseName)) + require.NoError(t, err) + + _, err = db.Exec(fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", databaseName)) + require.NoError(t, err) + db.Close() + + db, err = sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/%s", + setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name)) + require.NoError(t, err) + defer db.Close() + + _, err = db.Exec(data) + require.NoError(t, err) + db.Close() + + case setting.Database.Type.IsPostgreSQL(): + var db *sql.DB + var err error + if setting.Database.Host[0] == '/' { + db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@/?sslmode=%s&host=%s", + setting.Database.User, setting.Database.Passwd, setting.Database.SSLMode, setting.Database.Host)) + require.NoError(t, err) + } else { + db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/?sslmode=%s", + setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.SSLMode)) + require.NoError(t, err) + } + defer db.Close() + + _, err = db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", setting.Database.Name)) + require.NoError(t, err) + + _, err = db.Exec(fmt.Sprintf("CREATE DATABASE %s", setting.Database.Name)) + require.NoError(t, err) + db.Close() + + // Check if we need to setup a specific schema + if len(setting.Database.Schema) != 0 { + if setting.Database.Host[0] == '/' { + db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@/%s?sslmode=%s&host=%s", + setting.Database.User, setting.Database.Passwd, setting.Database.Name, setting.Database.SSLMode, setting.Database.Host)) + } else { + db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", + setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode)) + } + require.NoError(t, err) + + defer db.Close() + + schrows, err := db.Query(fmt.Sprintf("SELECT 1 FROM information_schema.schemata WHERE schema_name = '%s'", setting.Database.Schema)) + require.NoError(t, err) + if !assert.NotEmpty(t, schrows) { + return false + } + + if !schrows.Next() { + // Create and setup a DB schema + _, err = db.Exec(fmt.Sprintf("CREATE SCHEMA %s", setting.Database.Schema)) + require.NoError(t, err) + } + schrows.Close() + + // Make the user's default search path the created schema; this will affect new connections + _, err = db.Exec(fmt.Sprintf(`ALTER USER "%s" SET search_path = %s`, setting.Database.User, setting.Database.Schema)) + require.NoError(t, err) + + db.Close() + } + + if setting.Database.Host[0] == '/' { + db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@/%s?sslmode=%s&host=%s", + setting.Database.User, setting.Database.Passwd, setting.Database.Name, setting.Database.SSLMode, setting.Database.Host)) + } else { + db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", + setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode)) + } + require.NoError(t, err) + defer db.Close() + + _, err = db.Exec(data) + require.NoError(t, err) + db.Close() + } + return true +} + +func wrappedMigrate(x *xorm.Engine) error { + currentEngine = x + return migrations.Migrate(x) +} + +func doMigrationTest(t *testing.T, version string) { + defer tests.PrintCurrentTest(t)() + tests.Printf("Performing migration test for %s version: %s\n", setting.Database.Type, version) + if !restoreOldDB(t, version) { + return + } + + setting.InitSQLLoggersForCli(log.INFO) + + err := db.InitEngineWithMigration(context.Background(), wrappedMigrate) + require.NoError(t, err) + currentEngine.Close() + + beans, _ := db.NamesToBean() + + err = db.InitEngineWithMigration(context.Background(), func(x *xorm.Engine) error { + currentEngine = x + return migrate_base.RecreateTables(beans...)(x) + }) + require.NoError(t, err) + currentEngine.Close() + + // We do this a second time to ensure that there is not a problem with retained indices + err = db.InitEngineWithMigration(context.Background(), func(x *xorm.Engine) error { + currentEngine = x + return migrate_base.RecreateTables(beans...)(x) + }) + require.NoError(t, err) + + currentEngine.Close() +} + +func TestMigrations(t *testing.T) { + defer initMigrationTest(t)() + + dialect := setting.Database.Type + versions, err := availableVersions() + require.NoError(t, err) + + if len(versions) == 0 { + tests.Printf("No old database versions available to migration test for %s\n", dialect) + return + } + + tests.Printf("Preparing to test %d migrations for %s\n", len(versions), dialect) + for _, version := range versions { + t.Run(fmt.Sprintf("Migrate-%s-%s", dialect, version), func(t *testing.T) { + doMigrationTest(t, version) + }) + } +} diff --git a/tests/integration/milestone_test.go b/tests/integration/milestone_test.go new file mode 100644 index 0000000..ba46740 --- /dev/null +++ b/tests/integration/milestone_test.go @@ -0,0 +1,25 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestViewMilestones(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/user2/repo1/milestones") + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + search := htmlDoc.doc.Find(".list-header-search > .search > .input > input") + placeholder, _ := search.Attr("placeholder") + assert.Equal(t, "Search milestones...", placeholder) +} diff --git a/tests/integration/mirror_pull_test.go b/tests/integration/mirror_pull_test.go new file mode 100644 index 0000000..60fb47e --- /dev/null +++ b/tests/integration/mirror_pull_test.go @@ -0,0 +1,104 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/migration" + mirror_service "code.gitea.io/gitea/services/mirror" + release_service "code.gitea.io/gitea/services/release" + repo_service "code.gitea.io/gitea/services/repository" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMirrorPull(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repoPath := repo_model.RepoPath(user.Name, repo.Name) + + opts := migration.MigrateOptions{ + RepoName: "test_mirror", + Description: "Test mirror", + Private: false, + Mirror: true, + CloneAddr: repoPath, + Wiki: true, + Releases: false, + } + + mirrorRepo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user, user, repo_service.CreateRepoOptions{ + Name: opts.RepoName, + Description: opts.Description, + IsPrivate: opts.Private, + IsMirror: opts.Mirror, + Status: repo_model.RepositoryBeingMigrated, + }) + require.NoError(t, err) + assert.True(t, mirrorRepo.IsMirror, "expected pull-mirror repo to be marked as a mirror immediately after its creation") + + ctx := context.Background() + + mirror, err := repo_service.MigrateRepositoryGitData(ctx, user, mirrorRepo, opts, nil) + require.NoError(t, err) + + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo) + require.NoError(t, err) + defer gitRepo.Close() + + findOptions := repo_model.FindReleasesOptions{ + IncludeDrafts: true, + IncludeTags: true, + RepoID: mirror.ID, + } + initCount, err := db.Count[repo_model.Release](db.DefaultContext, findOptions) + require.NoError(t, err) + + require.NoError(t, release_service.CreateRelease(gitRepo, &repo_model.Release{ + RepoID: repo.ID, + Repo: repo, + PublisherID: user.ID, + Publisher: user, + TagName: "v0.2", + Target: "master", + Title: "v0.2 is released", + Note: "v0.2 is released", + IsDraft: false, + IsPrerelease: false, + IsTag: true, + }, "", []*release_service.AttachmentChange{})) + + _, err = repo_model.GetMirrorByRepoID(ctx, mirror.ID) + require.NoError(t, err) + + ok := mirror_service.SyncPullMirror(ctx, mirror.ID) + assert.True(t, ok) + + count, err := db.Count[repo_model.Release](db.DefaultContext, findOptions) + require.NoError(t, err) + assert.EqualValues(t, initCount+1, count) + + release, err := repo_model.GetRelease(db.DefaultContext, repo.ID, "v0.2") + require.NoError(t, err) + require.NoError(t, release_service.DeleteReleaseByID(ctx, repo, release, user, true)) + + ok = mirror_service.SyncPullMirror(ctx, mirror.ID) + assert.True(t, ok) + + count, err = db.Count[repo_model.Release](db.DefaultContext, findOptions) + require.NoError(t, err) + assert.EqualValues(t, initCount, count) +} diff --git a/tests/integration/mirror_push_test.go b/tests/integration/mirror_push_test.go new file mode 100644 index 0000000..fa62219 --- /dev/null +++ b/tests/integration/mirror_push_test.go @@ -0,0 +1,404 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + "time" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + gitea_context "code.gitea.io/gitea/services/context" + doctor "code.gitea.io/gitea/services/doctor" + "code.gitea.io/gitea/services/migrations" + mirror_service "code.gitea.io/gitea/services/mirror" + repo_service "code.gitea.io/gitea/services/repository" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMirrorPush(t *testing.T) { + onGiteaRun(t, testMirrorPush) +} + +func testMirrorPush(t *testing.T, u *url.URL) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)() + + require.NoError(t, migrations.Init()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + srcRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + mirrorRepo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user, user, repo_service.CreateRepoOptions{ + Name: "test-push-mirror", + }) + require.NoError(t, err) + + ctx := NewAPITestContext(t, user.LowerName, srcRepo.Name) + + doCreatePushMirror(ctx, fmt.Sprintf("%s%s/%s", u.String(), url.PathEscape(ctx.Username), url.PathEscape(mirrorRepo.Name)), user.LowerName, userPassword)(t) + doCreatePushMirror(ctx, fmt.Sprintf("%s%s/%s", u.String(), url.PathEscape(ctx.Username), url.PathEscape("does-not-matter")), user.LowerName, userPassword)(t) + + mirrors, _, err := repo_model.GetPushMirrorsByRepoID(db.DefaultContext, srcRepo.ID, db.ListOptions{}) + require.NoError(t, err) + assert.Len(t, mirrors, 2) + + ok := mirror_service.SyncPushMirror(context.Background(), mirrors[0].ID) + assert.True(t, ok) + + srcGitRepo, err := gitrepo.OpenRepository(git.DefaultContext, srcRepo) + require.NoError(t, err) + defer srcGitRepo.Close() + + srcCommit, err := srcGitRepo.GetBranchCommit("master") + require.NoError(t, err) + + mirrorGitRepo, err := gitrepo.OpenRepository(git.DefaultContext, mirrorRepo) + require.NoError(t, err) + defer mirrorGitRepo.Close() + + mirrorCommit, err := mirrorGitRepo.GetBranchCommit("master") + require.NoError(t, err) + + assert.Equal(t, srcCommit.ID, mirrorCommit.ID) + + // Test that we can "repair" push mirrors where the remote doesn't exist in git's state. + // To do that, we artificially remove the remote... + cmd := git.NewCommand(db.DefaultContext, "remote", "rm").AddDynamicArguments(mirrors[0].RemoteName) + _, _, err = cmd.RunStdString(&git.RunOpts{Dir: srcRepo.RepoPath()}) + require.NoError(t, err) + + // ...then ensure that trying to get its remote address fails + _, err = repo_model.GetPushMirrorRemoteAddress(srcRepo.OwnerName, srcRepo.Name, mirrors[0].RemoteName) + require.Error(t, err) + + // ...and that we can fix it. + err = doctor.FixPushMirrorsWithoutGitRemote(db.DefaultContext, nil, true) + require.NoError(t, err) + + // ...and after fixing, we only have one remote + mirrors, _, err = repo_model.GetPushMirrorsByRepoID(db.DefaultContext, srcRepo.ID, db.ListOptions{}) + require.NoError(t, err) + assert.Len(t, mirrors, 1) + + // ...one we can get the address of, and it's not the one we removed + remoteAddress, err := repo_model.GetPushMirrorRemoteAddress(srcRepo.OwnerName, srcRepo.Name, mirrors[0].RemoteName) + require.NoError(t, err) + assert.Contains(t, remoteAddress, "does-not-matter") + + // Cleanup + doRemovePushMirror(ctx, fmt.Sprintf("%s%s/%s", u.String(), url.PathEscape(ctx.Username), url.PathEscape(mirrorRepo.Name)), user.LowerName, userPassword, int(mirrors[0].ID))(t) + mirrors, _, err = repo_model.GetPushMirrorsByRepoID(db.DefaultContext, srcRepo.ID, db.ListOptions{}) + require.NoError(t, err) + assert.Empty(t, mirrors) +} + +func doCreatePushMirror(ctx APITestContext, address, username, password string) func(t *testing.T) { + return func(t *testing.T) { + csrf := GetCSRF(t, ctx.Session, fmt.Sprintf("/%s/%s/settings", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame))) + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)), map[string]string{ + "_csrf": csrf, + "action": "push-mirror-add", + "push_mirror_address": address, + "push_mirror_username": username, + "push_mirror_password": password, + "push_mirror_interval": "0", + }) + ctx.Session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := ctx.Session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.Contains(t, flashCookie.Value, "success") + } +} + +func doRemovePushMirror(ctx APITestContext, address, username, password string, pushMirrorID int) func(t *testing.T) { + return func(t *testing.T) { + csrf := GetCSRF(t, ctx.Session, fmt.Sprintf("/%s/%s/settings", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame))) + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)), map[string]string{ + "_csrf": csrf, + "action": "push-mirror-remove", + "push_mirror_id": strconv.Itoa(pushMirrorID), + "push_mirror_address": address, + "push_mirror_username": username, + "push_mirror_password": password, + "push_mirror_interval": "0", + }) + ctx.Session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := ctx.Session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.Contains(t, flashCookie.Value, "success") + } +} + +func TestSSHPushMirror(t *testing.T) { + _, err := exec.LookPath("ssh") + if err != nil { + t.Skip("SSH executable not present") + } + + onGiteaRun(t, func(t *testing.T, _ *url.URL) { + defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)() + defer test.MockVariableValue(&setting.Mirror.Enabled, true)() + defer test.MockVariableValue(&setting.SSH.RootPath, t.TempDir())() + require.NoError(t, migrations.Init()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + srcRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + assert.False(t, srcRepo.HasWiki()) + sess := loginUser(t, user.Name) + pushToRepo, _, f := tests.CreateDeclarativeRepoWithOptions(t, user, tests.DeclarativeRepoOptions{ + Name: optional.Some("push-mirror-test"), + AutoInit: optional.Some(false), + EnabledUnits: optional.Some([]unit.Type{unit.TypeCode}), + }) + defer f() + + sshURL := fmt.Sprintf("ssh://%s@%s/%s.git", setting.SSH.User, net.JoinHostPort(setting.SSH.ListenHost, strconv.Itoa(setting.SSH.ListenPort)), pushToRepo.FullName()) + t.Run("Mutual exclusive", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{ + "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())), + "action": "push-mirror-add", + "push_mirror_address": sshURL, + "push_mirror_username": "username", + "push_mirror_password": "password", + "push_mirror_use_ssh": "true", + "push_mirror_interval": "0", + }) + resp := sess.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + errMsg := htmlDoc.Find(".ui.negative.message").Text() + assert.Contains(t, errMsg, "Cannot use public key and password based authentication in combination.") + }) + + inputSelector := `input[id="push_mirror_use_ssh"]` + + t.Run("SSH not available", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&git.HasSSHExecutable, false)() + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{ + "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())), + "action": "push-mirror-add", + "push_mirror_address": sshURL, + "push_mirror_use_ssh": "true", + "push_mirror_interval": "0", + }) + resp := sess.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + errMsg := htmlDoc.Find(".ui.negative.message").Text() + assert.Contains(t, errMsg, "SSH authentication isn't available.") + + htmlDoc.AssertElement(t, inputSelector, false) + }) + + t.Run("SSH available", func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("/%s/settings", srcRepo.FullName())) + resp := sess.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, inputSelector, true) + }) + + t.Run("Normal", func(t *testing.T) { + var pushMirror *repo_model.PushMirror + t.Run("Adding", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{ + "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())), + "action": "push-mirror-add", + "push_mirror_address": sshURL, + "push_mirror_use_ssh": "true", + "push_mirror_interval": "0", + }) + sess.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := sess.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.Contains(t, flashCookie.Value, "success") + + pushMirror = unittest.AssertExistsAndLoadBean(t, &repo_model.PushMirror{RepoID: srcRepo.ID}) + assert.NotEmpty(t, pushMirror.PrivateKey) + assert.NotEmpty(t, pushMirror.PublicKey) + }) + + publickey := "" + t.Run("Publickey", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/%s/settings", srcRepo.FullName())) + resp := sess.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + publickey = htmlDoc.Find(".ui.table td a[data-clipboard-text]").AttrOr("data-clipboard-text", "") + assert.EqualValues(t, publickey, pushMirror.GetPublicKey()) + }) + + t.Run("Add deploy key", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings/keys", pushToRepo.FullName()), map[string]string{ + "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings/keys", pushToRepo.FullName())), + "title": "push mirror key", + "content": publickey, + "is_writable": "true", + }) + sess.MakeRequest(t, req, http.StatusSeeOther) + + unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "push mirror key", RepoID: pushToRepo.ID}) + }) + + t.Run("Synchronize", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{ + "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())), + "action": "push-mirror-sync", + "push_mirror_id": strconv.FormatInt(pushMirror.ID, 10), + }) + sess.MakeRequest(t, req, http.StatusSeeOther) + }) + + t.Run("Check mirrored content", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + shortSHA := "1032bbf17f" + + req := NewRequest(t, "GET", fmt.Sprintf("/%s", srcRepo.FullName())) + resp := sess.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + assert.Contains(t, htmlDoc.Find(".shortsha").Text(), shortSHA) + + assert.Eventually(t, func() bool { + req = NewRequest(t, "GET", fmt.Sprintf("/%s", pushToRepo.FullName())) + resp = sess.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + + return htmlDoc.Find(".shortsha").Text() == shortSHA + }, time.Second*30, time.Second) + }) + + t.Run("Check known host keys", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + knownHosts, err := os.ReadFile(filepath.Join(setting.SSH.RootPath, "known_hosts")) + require.NoError(t, err) + + publicKey, err := os.ReadFile(setting.SSH.ServerHostKeys[0] + ".pub") + require.NoError(t, err) + + assert.Contains(t, string(knownHosts), string(publicKey)) + }) + }) + }) +} + +func TestPushMirrorSettings(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)() + defer test.MockVariableValue(&setting.Mirror.Enabled, true)() + require.NoError(t, migrations.Init()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + srcRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + srcRepo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) + assert.False(t, srcRepo.HasWiki()) + sess := loginUser(t, user.Name) + pushToRepo, _, f := tests.CreateDeclarativeRepoWithOptions(t, user, tests.DeclarativeRepoOptions{ + Name: optional.Some("push-mirror-test"), + AutoInit: optional.Some(false), + EnabledUnits: optional.Some([]unit.Type{unit.TypeCode}), + }) + defer f() + + t.Run("Adding", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo2.FullName()), map[string]string{ + "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo2.FullName())), + "action": "push-mirror-add", + "push_mirror_address": u.String() + pushToRepo.FullName(), + "push_mirror_interval": "0", + }) + sess.MakeRequest(t, req, http.StatusSeeOther) + + req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{ + "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())), + "action": "push-mirror-add", + "push_mirror_address": u.String() + pushToRepo.FullName(), + "push_mirror_interval": "0", + }) + sess.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := sess.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.Contains(t, flashCookie.Value, "success") + }) + + mirrors, _, err := repo_model.GetPushMirrorsByRepoID(db.DefaultContext, srcRepo.ID, db.ListOptions{}) + require.NoError(t, err) + assert.Len(t, mirrors, 1) + mirrorID := mirrors[0].ID + + mirrors, _, err = repo_model.GetPushMirrorsByRepoID(db.DefaultContext, srcRepo2.ID, db.ListOptions{}) + require.NoError(t, err) + assert.Len(t, mirrors, 1) + + t.Run("Interval", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + unittest.AssertExistsAndLoadBean(t, &repo_model.PushMirror{ID: mirrorID - 1}) + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{ + "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())), + "action": "push-mirror-update", + "push_mirror_id": strconv.FormatInt(mirrorID-1, 10), + "push_mirror_interval": "10m0s", + }) + sess.MakeRequest(t, req, http.StatusNotFound) + + req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{ + "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())), + "action": "push-mirror-update", + "push_mirror_id": strconv.FormatInt(mirrorID, 10), + "push_mirror_interval": "10m0s", + }) + sess.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := sess.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.Contains(t, flashCookie.Value, "success") + }) + }) +} diff --git a/tests/integration/new_org_test.go b/tests/integration/new_org_test.go new file mode 100644 index 0000000..ec9f2f2 --- /dev/null +++ b/tests/integration/new_org_test.go @@ -0,0 +1,37 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "strings" + "testing" + + "code.gitea.io/gitea/modules/translation" + + "github.com/stretchr/testify/assert" +) + +func TestNewOrganizationForm(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + session := loginUser(t, "user1") + locale := translation.NewLocale("en-US") + + response := session.MakeRequest(t, NewRequest(t, "GET", "/org/create"), http.StatusOK) + page := NewHTMLParser(t, response.Body) + + // Verify page title + title := page.Find("title").Text() + assert.Contains(t, title, locale.TrString("new_org.title")) + + // Verify page form + _, exists := page.Find("form[action='/org/create']").Attr("method") + assert.True(t, exists) + + // Verify page header + header := strings.TrimSpace(page.Find(".form[action='/org/create'] .header").Text()) + assert.EqualValues(t, locale.TrString("new_org.title"), header) + }) +} diff --git a/tests/integration/nonascii_branches_test.go b/tests/integration/nonascii_branches_test.go new file mode 100644 index 0000000..8917a9b --- /dev/null +++ b/tests/integration/nonascii_branches_test.go @@ -0,0 +1,214 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "path" + "testing" + + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func testSrcRouteRedirect(t *testing.T, session *TestSession, user, repo, route, expectedLocation string, expectedStatus int) { + prefix := path.Join("/", user, repo, "src") + + // Make request + req := NewRequest(t, "GET", path.Join(prefix, route)) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + + // Check Location header + location := resp.Header().Get("Location") + assert.Equal(t, path.Join(prefix, expectedLocation), location) + + // Perform redirect + req = NewRequest(t, "GET", location) + session.MakeRequest(t, req, expectedStatus) +} + +func setDefaultBranch(t *testing.T, session *TestSession, user, repo, branch string) { + location := path.Join("/", user, repo, "settings/branches") + csrf := GetCSRF(t, session, location) + req := NewRequestWithValues(t, "POST", location, map[string]string{ + "_csrf": csrf, + "action": "default_branch", + "branch": branch, + }) + session.MakeRequest(t, req, http.StatusSeeOther) +} + +func TestNonasciiBranches(t *testing.T) { + testRedirects := []struct { + from string + to string + status int + }{ + // Branches + { + from: "master", + to: "branch/master", + status: http.StatusOK, + }, + { + from: "master/README.md", + to: "branch/master/README.md", + status: http.StatusOK, + }, + { + from: "master/badfile", + to: "branch/master/badfile", + status: http.StatusNotFound, // it does not exists + }, + { + from: "ГлавнаяВетка", + to: "branch/%D0%93%D0%BB%D0%B0%D0%B2%D0%BD%D0%B0%D1%8F%D0%92%D0%B5%D1%82%D0%BA%D0%B0", + status: http.StatusOK, + }, + { + from: "а/б/в", + to: "branch/%D0%B0/%D0%B1/%D0%B2", + status: http.StatusOK, + }, + { + from: "Grüßen/README.md", + to: "branch/Gr%C3%BC%C3%9Fen/README.md", + status: http.StatusOK, + }, + { + from: "Plus+Is+Not+Space", + to: "branch/Plus+Is+Not+Space", + status: http.StatusOK, + }, + { + from: "Plus+Is+Not+Space/Файл.md", + to: "branch/Plus+Is+Not+Space/%D0%A4%D0%B0%D0%B9%D0%BB.md", + status: http.StatusOK, + }, + { + from: "Plus+Is+Not+Space/and+it+is+valid.md", + to: "branch/Plus+Is+Not+Space/and+it+is+valid.md", + status: http.StatusOK, + }, + { + from: "ブランチ", + to: "branch/%E3%83%96%E3%83%A9%E3%83%B3%E3%83%81", + status: http.StatusOK, + }, + // Tags + { + from: "Тэг", + to: "tag/%D0%A2%D1%8D%D0%B3", + status: http.StatusOK, + }, + { + from: "Ё/人", + to: "tag/%D0%81/%E4%BA%BA", + status: http.StatusOK, + }, + { + from: "タグ", + to: "tag/%E3%82%BF%E3%82%B0", + status: http.StatusOK, + }, + { + from: "タグ/ファイル.md", + to: "tag/%E3%82%BF%E3%82%B0/%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB.md", + status: http.StatusOK, + }, + // Files + { + from: "README.md", + to: "branch/Plus+Is+Not+Space/README.md", + status: http.StatusOK, + }, + { + from: "Файл.md", + to: "branch/Plus+Is+Not+Space/%D0%A4%D0%B0%D0%B9%D0%BB.md", + status: http.StatusOK, + }, + { + from: "ファイル.md", + to: "branch/Plus+Is+Not+Space/%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB.md", + status: http.StatusNotFound, // it's not on default branch + }, + // Same but url-encoded (few tests) + { + from: "%E3%83%96%E3%83%A9%E3%83%B3%E3%83%81", + to: "branch/%E3%83%96%E3%83%A9%E3%83%B3%E3%83%81", + status: http.StatusOK, + }, + { + from: "%E3%82%BF%E3%82%b0", + to: "tag/%E3%82%BF%E3%82%B0", + status: http.StatusOK, + }, + { + from: "%D0%A4%D0%B0%D0%B9%D0%BB.md", + to: "branch/Plus+Is+Not+Space/%D0%A4%D0%B0%D0%B9%D0%BB.md", + status: http.StatusOK, + }, + { + from: "%D0%81%2F%E4%BA%BA", + to: "tag/%D0%81/%E4%BA%BA", + status: http.StatusOK, + }, + { + from: "Ё%2F%E4%BA%BA", + to: "tag/%D0%81/%E4%BA%BA", + status: http.StatusOK, + }, + { + from: "Plus+Is+Not+Space/%25%252525mightnotplaywell", + to: "branch/Plus+Is+Not+Space/%25%252525mightnotplaywell", + status: http.StatusOK, + }, + { + from: "Plus+Is+Not+Space/%25253Fisnotaquestion%25253F", + to: "branch/Plus+Is+Not+Space/%25253Fisnotaquestion%25253F", + status: http.StatusOK, + }, + { + from: "Plus+Is+Not+Space/" + url.PathEscape("%3Fis?and#afile"), + to: "branch/Plus+Is+Not+Space/" + url.PathEscape("%3Fis?and#afile"), + status: http.StatusOK, + }, + { + from: "Plus+Is+Not+Space/10%25.md", + to: "branch/Plus+Is+Not+Space/10%25.md", + status: http.StatusOK, + }, + { + from: "Plus+Is+Not+Space/" + url.PathEscape("This+file%20has 1space"), + to: "branch/Plus+Is+Not+Space/" + url.PathEscape("This+file%20has 1space"), + status: http.StatusOK, + }, + { + from: "Plus+Is+Not+Space/" + url.PathEscape("This+file%2520has 2 spaces"), + to: "branch/Plus+Is+Not+Space/" + url.PathEscape("This+file%2520has 2 spaces"), + status: http.StatusOK, + }, + { + from: "Plus+Is+Not+Space/" + url.PathEscape("£15&$6.txt"), + to: "branch/Plus+Is+Not+Space/" + url.PathEscape("£15&$6.txt"), + status: http.StatusOK, + }, + } + + defer tests.PrepareTestEnv(t)() + + user := "user2" + repo := "utf8" + session := loginUser(t, user) + + setDefaultBranch(t, session, user, repo, "Plus+Is+Not+Space") + + for _, test := range testRedirects { + testSrcRouteRedirect(t, session, user, repo, test.from, test.to, test.status) + } + + setDefaultBranch(t, session, user, repo, "master") +} diff --git a/tests/integration/oauth_test.go b/tests/integration/oauth_test.go new file mode 100644 index 0000000..f385b99 --- /dev/null +++ b/tests/integration/oauth_test.go @@ -0,0 +1,1323 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers/web/auth" + forgejo_context "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/tests" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthorizeNoClientID(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequest(t, "GET", "/login/oauth/authorize") + ctx := loginUser(t, "user2") + resp := ctx.MakeRequest(t, req, http.StatusBadRequest) + assert.Contains(t, resp.Body.String(), "Client ID not registered") +} + +func TestAuthorizeUnregisteredRedirect(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=UNREGISTERED&response_type=code&state=thestate") + ctx := loginUser(t, "user1") + resp := ctx.MakeRequest(t, req, http.StatusBadRequest) + assert.Contains(t, resp.Body.String(), "Unregistered Redirect URI") +} + +func TestAuthorizeUnsupportedResponseType(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=a&response_type=UNEXPECTED&state=thestate") + ctx := loginUser(t, "user1") + resp := ctx.MakeRequest(t, req, http.StatusSeeOther) + u, err := resp.Result().Location() + require.NoError(t, err) + assert.Equal(t, "unsupported_response_type", u.Query().Get("error")) + assert.Equal(t, "Only code response type is supported.", u.Query().Get("error_description")) +} + +func TestAuthorizeUnsupportedCodeChallengeMethod(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=a&response_type=code&state=thestate&code_challenge_method=UNEXPECTED") + ctx := loginUser(t, "user1") + resp := ctx.MakeRequest(t, req, http.StatusSeeOther) + u, err := resp.Result().Location() + require.NoError(t, err) + assert.Equal(t, "invalid_request", u.Query().Get("error")) + assert.Equal(t, "unsupported code challenge method", u.Query().Get("error_description")) +} + +func TestAuthorizeLoginRedirect(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequest(t, "GET", "/login/oauth/authorize") + assert.Contains(t, MakeRequest(t, req, http.StatusSeeOther).Body.String(), "/user/login") +} + +func TestAuthorizeShow(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=a&response_type=code&state=thestate") + ctx := loginUser(t, "user4") + resp := ctx.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, "#authorize-app", true) + htmlDoc.GetCSRF() +} + +func TestOAuth_AuthorizeConfidentialTwice(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // da7da3ba-9a13-4167-856f-3899de0b0138 a confidential client in models/fixtures/oauth2_application.yml + + // request authorization for the first time shows the grant page ... + authorizeURL := "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=a&response_type=code&state=thestate" + req := NewRequest(t, "GET", authorizeURL) + ctx := loginUser(t, "user4") + resp := ctx.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, "#authorize-app", true) + + // ... and the user grants the authorization + req = NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", + "redirect_uri": "a", + "state": "thestate", + "granted": "true", + }) + resp = ctx.MakeRequest(t, req, http.StatusSeeOther) + assert.Contains(t, test.RedirectURL(resp), "code=") + + // request authorization the second time and the grant page is not shown again, redirection happens immediately + req = NewRequest(t, "GET", authorizeURL) + resp = ctx.MakeRequest(t, req, http.StatusSeeOther) + assert.Contains(t, test.RedirectURL(resp), "code=") +} + +func TestOAuth_AuthorizePublicTwice(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // ce5a1322-42a7-11ed-b878-0242ac120002 is a public client in models/fixtures/oauth2_application.yml + authorizeURL := "/login/oauth/authorize?client_id=ce5a1322-42a7-11ed-b878-0242ac120002&redirect_uri=b&response_type=code&code_challenge_method=plain&code_challenge=CODE&state=thestate" + ctx := loginUser(t, "user4") + // a public client must be authorized every time + for _, name := range []string{"First", "Second"} { + t.Run(name, func(t *testing.T) { + req := NewRequest(t, "GET", authorizeURL) + resp := ctx.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, "#authorize-app", true) + + req = NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "client_id": "ce5a1322-42a7-11ed-b878-0242ac120002", + "redirect_uri": "b", + "state": "thestate", + "granted": "true", + }) + resp = ctx.MakeRequest(t, req, http.StatusSeeOther) + assert.Contains(t, test.RedirectURL(resp), "code=") + }) + } +} + +func TestAuthorizeRedirectWithExistingGrant(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=https%3A%2F%2Fexample.com%2Fxyzzy&response_type=code&state=thestate") + ctx := loginUser(t, "user1") + resp := ctx.MakeRequest(t, req, http.StatusSeeOther) + u, err := resp.Result().Location() + require.NoError(t, err) + assert.Equal(t, "thestate", u.Query().Get("state")) + assert.Greaterf(t, len(u.Query().Get("code")), 30, "authorization code '%s' should be longer then 30", u.Query().Get("code")) + u.RawQuery = "" + assert.Equal(t, "https://example.com/xyzzy", u.String()) +} + +func TestAuthorizePKCERequiredForPublicClient(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=ce5a1322-42a7-11ed-b878-0242ac120002&redirect_uri=http%3A%2F%2F127.0.0.1&response_type=code&state=thestate") + ctx := loginUser(t, "user1") + resp := ctx.MakeRequest(t, req, http.StatusSeeOther) + u, err := resp.Result().Location() + require.NoError(t, err) + assert.Equal(t, "invalid_request", u.Query().Get("error")) + assert.Equal(t, "PKCE is required for public clients", u.Query().Get("error_description")) +} + +func TestAccessTokenExchange(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "authorization_code", + "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", + "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", + "redirect_uri": "a", + "code": "authcode", + "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", + }) + resp := MakeRequest(t, req, http.StatusOK) + type response struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + } + parsed := new(response) + + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsed)) + assert.Greater(t, len(parsed.AccessToken), 10) + assert.Greater(t, len(parsed.RefreshToken), 10) +} + +func TestAccessTokenExchangeWithPublicClient(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "authorization_code", + "client_id": "ce5a1322-42a7-11ed-b878-0242ac120002", + "redirect_uri": "http://127.0.0.1", + "code": "authcodepublic", + "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", + }) + resp := MakeRequest(t, req, http.StatusOK) + type response struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + } + parsed := new(response) + + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsed)) + assert.Greater(t, len(parsed.AccessToken), 10) + assert.Greater(t, len(parsed.RefreshToken), 10) +} + +func TestAccessTokenExchangeJSON(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequestWithJSON(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "authorization_code", + "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", + "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", + "redirect_uri": "a", + "code": "authcode", + "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", + }) + resp := MakeRequest(t, req, http.StatusOK) + type response struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + } + parsed := new(response) + + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsed)) + assert.Greater(t, len(parsed.AccessToken), 10) + assert.Greater(t, len(parsed.RefreshToken), 10) +} + +func TestAccessTokenExchangeWithoutPKCE(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "authorization_code", + "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", + "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", + "redirect_uri": "a", + "code": "authcode", + }) + resp := MakeRequest(t, req, http.StatusBadRequest) + parsedError := new(auth.AccessTokenError) + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError)) + assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode)) + assert.Equal(t, "failed PKCE code challenge", parsedError.ErrorDescription) +} + +func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) { + defer tests.PrepareTestEnv(t)() + // invalid client id + req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "authorization_code", + "client_id": "???", + "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", + "redirect_uri": "a", + "code": "authcode", + "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", + }) + resp := MakeRequest(t, req, http.StatusBadRequest) + parsedError := new(auth.AccessTokenError) + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError)) + assert.Equal(t, "invalid_client", string(parsedError.ErrorCode)) + assert.Equal(t, "cannot load client with client id: '???'", parsedError.ErrorDescription) + + // invalid client secret + req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "authorization_code", + "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", + "client_secret": "???", + "redirect_uri": "a", + "code": "authcode", + "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", + }) + resp = MakeRequest(t, req, http.StatusBadRequest) + parsedError = new(auth.AccessTokenError) + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError)) + assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode)) + assert.Equal(t, "invalid client secret", parsedError.ErrorDescription) + + // invalid redirect uri + req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "authorization_code", + "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", + "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", + "redirect_uri": "???", + "code": "authcode", + "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", + }) + resp = MakeRequest(t, req, http.StatusBadRequest) + parsedError = new(auth.AccessTokenError) + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError)) + assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode)) + assert.Equal(t, "unexpected redirect URI", parsedError.ErrorDescription) + + // invalid authorization code + req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "authorization_code", + "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", + "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", + "redirect_uri": "a", + "code": "???", + "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", + }) + resp = MakeRequest(t, req, http.StatusBadRequest) + parsedError = new(auth.AccessTokenError) + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError)) + assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode)) + assert.Equal(t, "client is not authorized", parsedError.ErrorDescription) + + // invalid grant_type + req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "???", + "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", + "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", + "redirect_uri": "a", + "code": "authcode", + "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", + }) + resp = MakeRequest(t, req, http.StatusBadRequest) + parsedError = new(auth.AccessTokenError) + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError)) + assert.Equal(t, "unsupported_grant_type", string(parsedError.ErrorCode)) + assert.Equal(t, "Only refresh_token or authorization_code grant type is supported", parsedError.ErrorDescription) +} + +func TestAccessTokenExchangeWithBasicAuth(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "authorization_code", + "redirect_uri": "a", + "code": "authcode", + "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", + }) + req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9") + resp := MakeRequest(t, req, http.StatusOK) + type response struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + } + parsed := new(response) + + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsed)) + assert.Greater(t, len(parsed.AccessToken), 10) + assert.Greater(t, len(parsed.RefreshToken), 10) + + // use wrong client_secret + req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "authorization_code", + "redirect_uri": "a", + "code": "authcode", + "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", + }) + req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OmJsYWJsYQ==") + resp = MakeRequest(t, req, http.StatusBadRequest) + parsedError := new(auth.AccessTokenError) + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError)) + assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode)) + assert.Equal(t, "invalid client secret", parsedError.ErrorDescription) + + // missing header + req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "authorization_code", + "redirect_uri": "a", + "code": "authcode", + "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", + }) + resp = MakeRequest(t, req, http.StatusBadRequest) + parsedError = new(auth.AccessTokenError) + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError)) + assert.Equal(t, "invalid_client", string(parsedError.ErrorCode)) + assert.Equal(t, "cannot load client with client id: ''", parsedError.ErrorDescription) + + // client_id inconsistent with Authorization header + req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "authorization_code", + "redirect_uri": "a", + "code": "authcode", + "client_id": "inconsistent", + }) + req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9") + resp = MakeRequest(t, req, http.StatusBadRequest) + parsedError = new(auth.AccessTokenError) + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError)) + assert.Equal(t, "invalid_request", string(parsedError.ErrorCode)) + assert.Equal(t, "client_id in request body inconsistent with Authorization header", parsedError.ErrorDescription) + + // client_secret inconsistent with Authorization header + req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "authorization_code", + "redirect_uri": "a", + "code": "authcode", + "client_secret": "inconsistent", + }) + req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9") + resp = MakeRequest(t, req, http.StatusBadRequest) + parsedError = new(auth.AccessTokenError) + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError)) + assert.Equal(t, "invalid_request", string(parsedError.ErrorCode)) + assert.Equal(t, "client_secret in request body inconsistent with Authorization header", parsedError.ErrorDescription) +} + +func TestRefreshTokenInvalidation(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "authorization_code", + "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", + "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", + "redirect_uri": "a", + "code": "authcode", + "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", + }) + resp := MakeRequest(t, req, http.StatusOK) + type response struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + } + parsed := new(response) + + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsed)) + + // test without invalidation + setting.OAuth2.InvalidateRefreshTokens = false + + req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "refresh_token", + "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", + // omit secret + "redirect_uri": "a", + "refresh_token": parsed.RefreshToken, + }) + resp = MakeRequest(t, req, http.StatusBadRequest) + parsedError := new(auth.AccessTokenError) + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError)) + assert.Equal(t, "invalid_client", string(parsedError.ErrorCode)) + assert.Equal(t, "invalid empty client secret", 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": "a", + "refresh_token": "UNEXPECTED", + }) + resp = MakeRequest(t, req, http.StatusBadRequest) + parsedError = new(auth.AccessTokenError) + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError)) + 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": "a", + "refresh_token": parsed.RefreshToken, + }) + + bs, err := io.ReadAll(req.Body) + require.NoError(t, err) + + req.Body = io.NopCloser(bytes.NewReader(bs)) + MakeRequest(t, req, http.StatusOK) + + req.Body = io.NopCloser(bytes.NewReader(bs)) + MakeRequest(t, req, http.StatusOK) + + // test with invalidation + setting.OAuth2.InvalidateRefreshTokens = true + req.Body = io.NopCloser(bytes.NewReader(bs)) + MakeRequest(t, req, http.StatusOK) + + // repeat request should fail + req.Body = io.NopCloser(bytes.NewReader(bs)) + resp = MakeRequest(t, req, http.StatusBadRequest) + parsedError = new(auth.AccessTokenError) + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError)) + assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode)) + assert.Equal(t, "token was already used", parsedError.ErrorDescription) +} + +func TestSignInOAuthCallbackSignIn(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // + // OAuth2 authentication source GitLab + // + gitlabName := "gitlab" + gitlab := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName)) + + // + // Create a user as if it had been previously been created by the GitLab + // authentication source. + // + userGitLabUserID := "5678" + userGitLab := &user_model.User{ + Name: "gitlabuser", + Email: "gitlabuser@example.com", + Passwd: "gitlabuserpassword", + Type: user_model.UserTypeIndividual, + LoginType: auth_model.OAuth2, + LoginSource: gitlab.ID, + LoginName: userGitLabUserID, + } + defer createUser(context.Background(), t, userGitLab)() + + // + // A request for user information sent to Goth will return a + // goth.User exactly matching the user created above. + // + defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) { + return goth.User{ + Provider: gitlabName, + UserID: userGitLabUserID, + Email: userGitLab.Email, + }, nil + })() + req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName)) + resp := MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/", test.RedirectURL(resp)) + userAfterLogin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userGitLab.ID}) + assert.Greater(t, userAfterLogin.LastLoginUnix, userGitLab.LastLoginUnix) +} + +func TestSignInOAuthCallbackWithoutPKCEWhenUnsupported(t *testing.T) { + // https://codeberg.org/forgejo/forgejo/issues/4033 + defer tests.PrepareTestEnv(t)() + + // Setup authentication source + gitlabName := "gitlab" + gitlab := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName)) + // Create a user as if it had been previously been created by the authentication source. + userGitLabUserID := "5678" + userGitLab := &user_model.User{ + Name: "gitlabuser", + Email: "gitlabuser@example.com", + Passwd: "gitlabuserpassword", + Type: user_model.UserTypeIndividual, + LoginType: auth_model.OAuth2, + LoginSource: gitlab.ID, + LoginName: userGitLabUserID, + } + defer createUser(context.Background(), t, userGitLab)() + + // initial redirection (to generate the code_challenge) + session := emptyTestSession(t) + req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s", gitlabName)) + resp := session.MakeRequest(t, req, http.StatusTemporaryRedirect) + dest, err := url.Parse(resp.Header().Get("Location")) + require.NoError(t, err) + assert.Empty(t, dest.Query().Get("code_challenge_method")) + assert.Empty(t, dest.Query().Get("code_challenge")) + + // callback (to check the initial code_challenge) + defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) { + assert.Empty(t, req.URL.Query().Get("code_verifier")) + return goth.User{ + Provider: gitlabName, + UserID: userGitLabUserID, + Email: userGitLab.Email, + }, nil + })() + req = NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName)) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/", test.RedirectURL(resp)) + unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userGitLab.ID}) +} + +func TestSignInOAuthCallbackPKCE(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + // Setup authentication source + sourceName := "oidc" + authSource := addAuthSource(t, authSourcePayloadOpenIDConnect(sourceName, u.String())) + // Create a user as if it had been previously been created by the authentication source. + userID := "5678" + user := &user_model.User{ + Name: "oidc.user", + Email: "oidc.user@example.com", + Passwd: "oidc.userpassword", + Type: user_model.UserTypeIndividual, + LoginType: auth_model.OAuth2, + LoginSource: authSource.ID, + LoginName: userID, + } + defer createUser(context.Background(), t, user)() + + // initial redirection (to generate the code_challenge) + session := emptyTestSession(t) + req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s", sourceName)) + resp := session.MakeRequest(t, req, http.StatusTemporaryRedirect) + dest, err := url.Parse(resp.Header().Get("Location")) + require.NoError(t, err) + assert.Equal(t, "S256", dest.Query().Get("code_challenge_method")) + codeChallenge := dest.Query().Get("code_challenge") + assert.NotEmpty(t, codeChallenge) + + // callback (to check the initial code_challenge) + defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) { + codeVerifier := req.URL.Query().Get("code_verifier") + assert.NotEmpty(t, codeVerifier) + assert.Greater(t, len(codeVerifier), 40, codeVerifier) + + sha2 := sha256.New() + io.WriteString(sha2, codeVerifier) + assert.Equal(t, codeChallenge, base64.RawURLEncoding.EncodeToString(sha2.Sum(nil))) + + return goth.User{ + Provider: sourceName, + UserID: userID, + Email: user.Email, + }, nil + })() + req = NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", sourceName)) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/", test.RedirectURL(resp)) + unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: user.ID}) + }) +} + +func TestSignInOAuthCallbackRedirectToEscaping(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // + // OAuth2 authentication source GitLab + // + gitlabName := "gitlab" + gitlab := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName)) + + // + // Create a user as if it had been previously created by the GitLab + // authentication source. + // + userGitLabUserID := "5678" + userGitLab := &user_model.User{ + Name: "gitlabuser", + Email: "gitlabuser@example.com", + Passwd: "gitlabuserpassword", + Type: user_model.UserTypeIndividual, + LoginType: auth_model.OAuth2, + LoginSource: gitlab.ID, + LoginName: userGitLabUserID, + } + defer createUser(context.Background(), t, userGitLab)() + + // + // A request for user information sent to Goth will return a + // goth.User exactly matching the user created above. + // + defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) { + return goth.User{ + Provider: gitlabName, + UserID: userGitLabUserID, + Email: userGitLab.Email, + }, nil + })() + req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName)) + req.AddCookie(&http.Cookie{ + Name: "redirect_to", + Value: "/login/oauth/authorize?redirect_uri=https%3A%2F%2Ftranslate.example.org", + Path: "/", + }) + resp := MakeRequest(t, req, http.StatusSeeOther) + + hasNewSessionCookie := false + sessionCookieName := setting.SessionConfig.CookieName + for _, c := range resp.Result().Cookies() { + if c.Name == sessionCookieName { + hasNewSessionCookie = true + break + } + t.Log("Got cookie", c.Name) + } + + assert.True(t, hasNewSessionCookie, "Session cookie %q is missing", sessionCookieName) + assert.Equal(t, "/login/oauth/authorize?redirect_uri=https://translate.example.org", test.RedirectURL(resp)) +} + +func TestSignUpViaOAuthWithMissingFields(t *testing.T) { + defer tests.PrepareTestEnv(t)() + // enable auto-creation of accounts via OAuth2 + enableAutoRegistration := setting.OAuth2Client.EnableAutoRegistration + setting.OAuth2Client.EnableAutoRegistration = true + defer func() { + setting.OAuth2Client.EnableAutoRegistration = enableAutoRegistration + }() + + // OAuth2 authentication source GitLab + gitlabName := "gitlab" + addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName)) + userGitLabUserID := "5678" + + // The Goth User returned by the oauth2 integration is missing + // an email address, so we won't be able to automatically create a local account for it. + defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) { + return goth.User{ + Provider: gitlabName, + UserID: userGitLabUserID, + }, nil + })() + req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName)) + resp := MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/user/link_account", test.RedirectURL(resp)) +} + +func TestOAuth_GrantApplicationOAuth(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=a&response_type=code&state=thestate") + ctx := loginUser(t, "user4") + resp := ctx.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, "#authorize-app", true) + + req = NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", + "redirect_uri": "a", + "state": "thestate", + "granted": "false", + }) + resp = ctx.MakeRequest(t, req, http.StatusSeeOther) + assert.Contains(t, test.RedirectURL(resp), "error=access_denied&error_description=the+request+is+denied") +} + +func TestOAuthIntrospection(t *testing.T) { + defer tests.PrepareTestEnv(t)() + req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "grant_type": "authorization_code", + "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", + "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", + "redirect_uri": "a", + "code": "authcode", + "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", + }) + resp := MakeRequest(t, req, http.StatusOK) + type response struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + } + parsed := new(response) + + DecodeJSON(t, resp, parsed) + assert.Greater(t, len(parsed.AccessToken), 10) + assert.Greater(t, len(parsed.RefreshToken), 10) + + type introspectResponse struct { + Active bool `json:"active"` + Scope string `json:"scope,omitempty"` + Username string `json:"username"` + } + + // successful request with a valid client_id/client_secret and a valid token + t.Run("successful request with valid token", func(t *testing.T) { + req := NewRequestWithValues(t, "POST", "/login/oauth/introspect", map[string]string{ + "token": parsed.AccessToken, + }) + req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9") + resp := MakeRequest(t, req, http.StatusOK) + + introspectParsed := new(introspectResponse) + DecodeJSON(t, resp, introspectParsed) + assert.True(t, introspectParsed.Active) + assert.Equal(t, "user1", introspectParsed.Username) + }) + + // successful request with a valid client_id/client_secret, but an invalid token + t.Run("successful request with invalid token", func(t *testing.T) { + req := NewRequestWithValues(t, "POST", "/login/oauth/introspect", map[string]string{ + "token": "xyzzy", + }) + req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9") + resp := MakeRequest(t, req, http.StatusOK) + introspectParsed := new(introspectResponse) + DecodeJSON(t, resp, introspectParsed) + assert.False(t, introspectParsed.Active) + }) + + // unsuccessful request with an invalid client_id/client_secret + t.Run("unsuccessful request due to invalid basic auth", func(t *testing.T) { + req := NewRequestWithValues(t, "POST", "/login/oauth/introspect", map[string]string{ + "token": parsed.AccessToken, + }) + req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpK") + resp := MakeRequest(t, req, http.StatusUnauthorized) + assert.Contains(t, resp.Body.String(), "no valid authorization") + }) +} + +func requireCookieCSRF(t *testing.T, resp http.ResponseWriter) string { + for _, c := range resp.(*httptest.ResponseRecorder).Result().Cookies() { + if c.Name == "_csrf" { + return c.Value + } + } + require.True(t, false, "_csrf not found in cookies") + return "" +} + +func TestOAuth_GrantScopesReadUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + appBody := api.CreateOAuth2ApplicationOptions{ + Name: "oauth-provider-scopes-test", + RedirectURIs: []string{ + "a", + }, + ConfidentialClient: true, + } + + req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusCreated) + + var app *api.OAuth2Application + DecodeJSON(t, resp, &app) + + grant := &auth_model.OAuth2Grant{ + ApplicationID: app.ID, + UserID: user.ID, + Scope: "openid profile email read:user", + } + + err := db.Insert(db.DefaultContext, grant) + require.NoError(t, err) + + assert.Contains(t, grant.Scope, "openid profile email read:user") + + ctx := loginUserWithPasswordRemember(t, user.Name, "password", true) + + authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID) + authorizeReq := NewRequest(t, "GET", authorizeURL) + authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther) + + authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&")[0] + grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{ + "_csrf": requireCookieCSRF(t, authorizeResp), + "client_id": app.ClientID, + "redirect_uri": "a", + "state": "thestate", + "granted": "true", + }) + grantResp := ctx.MakeRequest(t, grantReq, http.StatusBadRequest) + assert.NotContains(t, grantResp.Body.String(), forgejo_context.CsrfErrorString) + + accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "_csrf": requireCookieCSRF(t, authorizeResp), + "grant_type": "authorization_code", + "client_id": app.ClientID, + "client_secret": app.ClientSecret, + "redirect_uri": "a", + "code": authcode, + }) + accessTokenResp := ctx.MakeRequest(t, accessTokenReq, 200) + type response struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + } + parsed := new(response) + + require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed)) + userReq := NewRequest(t, "GET", "/api/v1/user") + userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken) + userResp := MakeRequest(t, userReq, http.StatusOK) + + // assert.Contains(t, string(userResp.Body.Bytes()), "blah") + type userResponse struct { + Login string `json:"login"` + Email string `json:"email"` + } + + userParsed := new(userResponse) + require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), userParsed)) + assert.Contains(t, userParsed.Email, "user2@example.com") +} + +func TestOAuth_GrantScopesFailReadRepository(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + appBody := api.CreateOAuth2ApplicationOptions{ + Name: "oauth-provider-scopes-test", + RedirectURIs: []string{ + "a", + }, + ConfidentialClient: true, + } + + req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusCreated) + + var app *api.OAuth2Application + DecodeJSON(t, resp, &app) + + grant := &auth_model.OAuth2Grant{ + ApplicationID: app.ID, + UserID: user.ID, + Scope: "openid profile email read:user", + } + + err := db.Insert(db.DefaultContext, grant) + require.NoError(t, err) + + assert.Contains(t, grant.Scope, "openid profile email read:user") + + ctx := loginUserWithPasswordRemember(t, user.Name, "password", true) + + authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID) + authorizeReq := NewRequest(t, "GET", authorizeURL) + authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther) + + authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&")[0] + grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{ + "_csrf": requireCookieCSRF(t, authorizeResp), + "client_id": app.ClientID, + "redirect_uri": "a", + "state": "thestate", + "granted": "true", + }) + grantResp := ctx.MakeRequest(t, grantReq, http.StatusBadRequest) + assert.NotContains(t, grantResp.Body.String(), forgejo_context.CsrfErrorString) + + accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "_csrf": requireCookieCSRF(t, authorizeResp), + "grant_type": "authorization_code", + "client_id": app.ClientID, + "client_secret": app.ClientSecret, + "redirect_uri": "a", + "code": authcode, + }) + accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK) + type response struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + } + parsed := new(response) + + require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed)) + userReq := NewRequest(t, "GET", "/api/v1/users/user2/repos") + userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken) + userResp := MakeRequest(t, userReq, http.StatusForbidden) + + type userResponse struct { + Message string `json:"message"` + } + + userParsed := new(userResponse) + require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), userParsed)) + assert.Contains(t, userParsed.Message, "token does not have at least one of required scope(s): [read:repository]") +} + +func TestOAuth_GrantScopesReadRepository(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + appBody := api.CreateOAuth2ApplicationOptions{ + Name: "oauth-provider-scopes-test", + RedirectURIs: []string{ + "a", + }, + ConfidentialClient: true, + } + + req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody). + AddBasicAuth(user.Name) + resp := MakeRequest(t, req, http.StatusCreated) + + var app *api.OAuth2Application + DecodeJSON(t, resp, &app) + + grant := &auth_model.OAuth2Grant{ + ApplicationID: app.ID, + UserID: user.ID, + Scope: "openid profile email read:user read:repository", + } + + err := db.Insert(db.DefaultContext, grant) + require.NoError(t, err) + + assert.Contains(t, grant.Scope, "openid profile email read:user read:repository") + + ctx := loginUserWithPasswordRemember(t, user.Name, "password", true) + + authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID) + authorizeReq := NewRequest(t, "GET", authorizeURL) + authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther) + + authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&")[0] + grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{ + "_csrf": requireCookieCSRF(t, authorizeResp), + "client_id": app.ClientID, + "redirect_uri": "a", + "state": "thestate", + "granted": "true", + }) + grantResp := ctx.MakeRequest(t, grantReq, http.StatusBadRequest) + assert.NotContains(t, grantResp.Body.String(), forgejo_context.CsrfErrorString) + + accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "_csrf": requireCookieCSRF(t, authorizeResp), + "grant_type": "authorization_code", + "client_id": app.ClientID, + "client_secret": app.ClientSecret, + "redirect_uri": "a", + "code": authcode, + }) + accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK) + type response struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + } + parsed := new(response) + + require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed)) + userReq := NewRequest(t, "GET", "/api/v1/users/user2/repos") + userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken) + userResp := MakeRequest(t, userReq, http.StatusOK) + + type repos struct { + FullRepoName string `json:"full_name"` + } + var userResponse []*repos + require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), &userResponse)) + if assert.NotEmpty(t, userResponse) { + assert.Contains(t, userResponse[0].FullRepoName, "user2/repo1") + } +} + +func TestOAuth_GrantScopesReadPrivateGroups(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // setting.OAuth2.EnableAdditionalGrantScopes = true + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"}) + + appBody := api.CreateOAuth2ApplicationOptions{ + Name: "oauth-provider-scopes-test", + RedirectURIs: []string{ + "a", + }, + ConfidentialClient: true, + } + + appReq := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody). + AddBasicAuth(user.Name) + appResp := MakeRequest(t, appReq, http.StatusCreated) + + var app *api.OAuth2Application + DecodeJSON(t, appResp, &app) + + grant := &auth_model.OAuth2Grant{ + ApplicationID: app.ID, + UserID: user.ID, + Scope: "openid profile email groups read:user", + } + + err := db.Insert(db.DefaultContext, grant) + require.NoError(t, err) + + assert.Contains(t, grant.Scope, "openid profile email groups read:user") + + ctx := loginUserWithPasswordRemember(t, user.Name, "password", true) + + authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID) + authorizeReq := NewRequest(t, "GET", authorizeURL) + authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther) + + authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&")[0] + grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{ + "_csrf": requireCookieCSRF(t, authorizeResp), + "client_id": app.ClientID, + "redirect_uri": "a", + "state": "thestate", + "granted": "true", + }) + grantResp := ctx.MakeRequest(t, grantReq, http.StatusBadRequest) + assert.NotContains(t, grantResp.Body.String(), forgejo_context.CsrfErrorString) + + accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "_csrf": requireCookieCSRF(t, authorizeResp), + "grant_type": "authorization_code", + "client_id": app.ClientID, + "client_secret": app.ClientSecret, + "redirect_uri": "a", + "code": authcode, + }) + accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK) + type response struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token,omitempty"` + } + parsed := new(response) + require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed)) + parts := strings.Split(parsed.IDToken, ".") + + payload, _ := base64.RawURLEncoding.DecodeString(parts[1]) + type IDTokenClaims struct { + Groups []string `json:"groups"` + } + + claims := new(IDTokenClaims) + require.NoError(t, json.Unmarshal(payload, claims)) + for _, group := range []string{"limited_org36", "limited_org36:team20writepackage", "org6", "org6:owners", "org7", "org7:owners", "privated_org", "privated_org:team14writeauth"} { + assert.Contains(t, claims.Groups, group) + } +} + +func TestOAuth_GrantScopesReadOnlyPublicGroups(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + setting.OAuth2.EnableAdditionalGrantScopes = true + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"}) + + appBody := api.CreateOAuth2ApplicationOptions{ + Name: "oauth-provider-scopes-test", + RedirectURIs: []string{ + "a", + }, + ConfidentialClient: true, + } + + appReq := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody). + AddBasicAuth(user.Name) + appResp := MakeRequest(t, appReq, http.StatusCreated) + + var app *api.OAuth2Application + DecodeJSON(t, appResp, &app) + + grant := &auth_model.OAuth2Grant{ + ApplicationID: app.ID, + UserID: user.ID, + Scope: "openid profile email groups read:user", + } + + err := db.Insert(db.DefaultContext, grant) + require.NoError(t, err) + + assert.Contains(t, grant.Scope, "openid profile email groups read:user") + + ctx := loginUserWithPasswordRemember(t, user.Name, "password", true) + + authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID) + authorizeReq := NewRequest(t, "GET", authorizeURL) + authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther) + + authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&")[0] + grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{ + "_csrf": requireCookieCSRF(t, authorizeResp), + "client_id": app.ClientID, + "redirect_uri": "a", + "state": "thestate", + "granted": "true", + }) + grantResp := ctx.MakeRequest(t, grantReq, http.StatusBadRequest) + assert.NotContains(t, grantResp.Body.String(), forgejo_context.CsrfErrorString) + + accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "_csrf": requireCookieCSRF(t, authorizeResp), + "grant_type": "authorization_code", + "client_id": app.ClientID, + "client_secret": app.ClientSecret, + "redirect_uri": "a", + "code": authcode, + }) + accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK) + type response struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token,omitempty"` + } + parsed := new(response) + require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed)) + parts := strings.Split(parsed.IDToken, ".") + + payload, _ := base64.RawURLEncoding.DecodeString(parts[1]) + type IDTokenClaims struct { + Groups []string `json:"groups"` + } + + claims := new(IDTokenClaims) + require.NoError(t, json.Unmarshal(payload, claims)) + for _, privOrg := range []string{"org7", "org7:owners", "privated_org", "privated_org:team14writeauth"} { + assert.NotContains(t, claims.Groups, privOrg) + } + + userReq := NewRequest(t, "GET", "/login/oauth/userinfo") + userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken) + userResp := MakeRequest(t, userReq, http.StatusOK) + + type userinfo struct { + Groups []string `json:"groups"` + } + parsedUserInfo := new(userinfo) + require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), parsedUserInfo)) + + for _, privOrg := range []string{"org7", "org7:owners", "privated_org", "privated_org:team14writeauth"} { + assert.NotContains(t, parsedUserInfo.Groups, privOrg) + } +} + +func TestOAuth_GrantScopesReadPublicGroupsWithTheReadScope(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + setting.OAuth2.EnableAdditionalGrantScopes = true + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"}) + + appBody := api.CreateOAuth2ApplicationOptions{ + Name: "oauth-provider-scopes-test", + RedirectURIs: []string{ + "a", + }, + ConfidentialClient: true, + } + + appReq := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody). + AddBasicAuth(user.Name) + appResp := MakeRequest(t, appReq, http.StatusCreated) + + var app *api.OAuth2Application + DecodeJSON(t, appResp, &app) + + grant := &auth_model.OAuth2Grant{ + ApplicationID: app.ID, + UserID: user.ID, + Scope: "openid profile email groups read:user read:organization", + } + + err := db.Insert(db.DefaultContext, grant) + require.NoError(t, err) + + assert.Contains(t, grant.Scope, "openid profile email groups read:user read:organization") + + ctx := loginUserWithPasswordRemember(t, user.Name, "password", true) + + authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID) + authorizeReq := NewRequest(t, "GET", authorizeURL) + authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther) + + authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&")[0] + grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{ + "_csrf": requireCookieCSRF(t, authorizeResp), + "client_id": app.ClientID, + "redirect_uri": "a", + "state": "thestate", + "granted": "true", + }) + grantResp := ctx.MakeRequest(t, grantReq, http.StatusBadRequest) + assert.NotContains(t, grantResp.Body.String(), forgejo_context.CsrfErrorString) + + accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ + "_csrf": requireCookieCSRF(t, authorizeResp), + "grant_type": "authorization_code", + "client_id": app.ClientID, + "client_secret": app.ClientSecret, + "redirect_uri": "a", + "code": authcode, + }) + accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK) + type response struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token,omitempty"` + } + parsed := new(response) + require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed)) + parts := strings.Split(parsed.IDToken, ".") + + payload, _ := base64.RawURLEncoding.DecodeString(parts[1]) + type IDTokenClaims struct { + Groups []string `json:"groups"` + } + + claims := new(IDTokenClaims) + require.NoError(t, json.Unmarshal(payload, claims)) + for _, privOrg := range []string{"org7", "org7:owners", "privated_org", "privated_org:team14writeauth"} { + assert.Contains(t, claims.Groups, privOrg) + } + + userReq := NewRequest(t, "GET", "/login/oauth/userinfo") + userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken) + userResp := MakeRequest(t, userReq, http.StatusOK) + + type userinfo struct { + Groups []string `json:"groups"` + } + parsedUserInfo := new(userinfo) + require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), parsedUserInfo)) + for _, privOrg := range []string{"org7", "org7:owners", "privated_org", "privated_org:team14writeauth"} { + assert.Contains(t, parsedUserInfo.Groups, privOrg) + } +} diff --git a/tests/integration/opengraph_test.go b/tests/integration/opengraph_test.go new file mode 100644 index 0000000..8d29e45 --- /dev/null +++ b/tests/integration/opengraph_test.go @@ -0,0 +1,150 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" +) + +func TestOpenGraphProperties(t *testing.T) { + defer tests.PrepareTestEnv(t)() + siteName := "Forgejo: Beyond coding. We Forge." + + cases := []struct { + name string + url string + expected map[string]string + }{ + { + name: "website root", + url: "/", + expected: map[string]string{ + "og:title": siteName, + "og:url": setting.AppURL, + "og:description": "Forgejo is a self-hosted lightweight software forge. Easy to install and low maintenance, it just does the job.", + "og:type": "website", + "og:image": "/assets/img/logo.png", + "og:site_name": siteName, + }, + }, + { + name: "profile page without description", + url: "/user30", + expected: map[string]string{ + "og:title": "User Thirty", + "og:url": setting.AppURL + "user30", + "og:type": "profile", + "og:image": "https://secure.gravatar.com/avatar/eae1f44b34ff27284cb0792c7601c89c?d=identicon", + "og:site_name": siteName, + }, + }, + { + name: "profile page with description", + url: "/the_34-user.with.all.allowedchars", + expected: map[string]string{ + "og:title": "the_1-user.with.all.allowedChars", + "og:url": setting.AppURL + "the_34-user.with.all.allowedChars", + "og:description": "some [commonmark](https://commonmark.org/)!", + "og:type": "profile", + "og:image": setting.AppURL + "avatars/avatar34", + "og:site_name": siteName, + }, + }, + { + name: "issue", + url: "/user2/repo1/issues/1", + expected: map[string]string{ + "og:title": "issue1", + "og:url": setting.AppURL + "user2/repo1/issues/1", + "og:description": "content for the first issue", + "og:type": "object", + "og:image": "https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon", + "og:site_name": siteName, + }, + }, + { + name: "pull request", + url: "/user2/repo1/pulls/2", + expected: map[string]string{ + "og:title": "issue2", + "og:url": setting.AppURL + "user2/repo1/pulls/2", + "og:description": "content for the second issue", + "og:type": "object", + "og:image": "https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon", + "og:site_name": siteName, + }, + }, + { + name: "file in repo", + url: "/user27/repo49/src/branch/master/test/test.txt", + expected: map[string]string{ + "og:title": "repo49/test/test.txt at master", + "og:url": setting.AppURL + "/user27/repo49/src/branch/master/test/test.txt", + "og:type": "object", + "og:image": "https://secure.gravatar.com/avatar/7095710e927665f1bdd1ced94152f232?d=identicon", + "og:site_name": siteName, + }, + }, + { + name: "wiki page for repo without description", + url: "/user2/repo1/wiki/Page-With-Spaced-Name", + expected: map[string]string{ + "og:title": "Page With Spaced Name", + "og:url": setting.AppURL + "/user2/repo1/wiki/Page-With-Spaced-Name", + "og:type": "object", + "og:image": "https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon", + "og:site_name": siteName, + }, + }, + { + name: "index page for repo without description", + url: "/user2/repo1", + expected: map[string]string{ + "og:title": "repo1", + "og:url": setting.AppURL + "user2/repo1", + "og:type": "object", + "og:image": "https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon", + "og:site_name": siteName, + }, + }, + { + name: "index page for repo with description", + url: "/user27/repo49", + expected: map[string]string{ + "og:title": "repo49", + "og:url": setting.AppURL + "user27/repo49", + "og:description": "A wonderful repository with more than just a README.md", + "og:type": "object", + "og:image": "https://secure.gravatar.com/avatar/7095710e927665f1bdd1ced94152f232?d=identicon", + "og:site_name": siteName, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := NewRequest(t, "GET", tc.url) + resp := MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + foundProps := make(map[string]string) + doc.Find("head meta[property^=\"og:\"]").Each(func(_ int, selection *goquery.Selection) { + prop, foundProp := selection.Attr("property") + assert.True(t, foundProp) + content, foundContent := selection.Attr("content") + assert.True(t, foundContent, "opengraph meta tag without a content property") + foundProps[prop] = content + }) + + assert.EqualValues(t, tc.expected, foundProps, "mismatching opengraph properties") + }) + } +} diff --git a/tests/integration/org_count_test.go b/tests/integration/org_count_test.go new file mode 100644 index 0000000..e3de925 --- /dev/null +++ b/tests/integration/org_count_test.go @@ -0,0 +1,149 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/url" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOrgCounts(t *testing.T) { + onGiteaRun(t, testOrgCounts) +} + +func testOrgCounts(t *testing.T, u *url.URL) { + orgOwner := "user2" + orgName := "testOrg" + orgCollaborator := "user4" + ctx := NewAPITestContext(t, orgOwner, "repo1", auth_model.AccessTokenScopeWriteOrganization) + + var ownerCountRepos map[string]int + var collabCountRepos map[string]int + + t.Run("GetTheOwnersNumRepos", doCheckOrgCounts(orgOwner, map[string]int{}, + false, + func(_ *testing.T, calcOrgCounts map[string]int) { + ownerCountRepos = calcOrgCounts + }, + )) + t.Run("GetTheCollaboratorsNumRepos", doCheckOrgCounts(orgCollaborator, map[string]int{}, + false, + func(_ *testing.T, calcOrgCounts map[string]int) { + collabCountRepos = calcOrgCounts + }, + )) + + t.Run("CreatePublicTestOrganization", doAPICreateOrganization(ctx, &api.CreateOrgOption{ + UserName: orgName, + Visibility: "public", + })) + + // Following the creation of the organization, the orgName must appear in the counts with 0 repos + ownerCountRepos[orgName] = 0 + + t.Run("AssertNumRepos0ForTestOrg", doCheckOrgCounts(orgOwner, ownerCountRepos, true)) + + // the collaborator is not a collaborator yet + t.Run("AssertNoTestOrgReposForCollaborator", doCheckOrgCounts(orgCollaborator, collabCountRepos, true)) + + t.Run("CreateOrganizationPrivateRepo", doAPICreateOrganizationRepository(ctx, orgName, &api.CreateRepoOption{ + Name: "privateTestRepo", + AutoInit: true, + Private: true, + })) + + ownerCountRepos[orgName] = 1 + t.Run("AssertNumRepos1ForTestOrg", doCheckOrgCounts(orgOwner, ownerCountRepos, true)) + + t.Run("AssertNoTestOrgReposForCollaborator", doCheckOrgCounts(orgCollaborator, collabCountRepos, true)) + + var testTeam api.Team + + t.Run("CreateTeamForPublicTestOrganization", doAPICreateOrganizationTeam(ctx, orgName, &api.CreateTeamOption{ + Name: "test", + Permission: "read", + Units: []string{"repo.code", "repo.issues", "repo.wiki", "repo.pulls", "repo.releases"}, + CanCreateOrgRepo: true, + }, func(_ *testing.T, team api.Team) { + testTeam = team + })) + + t.Run("AssertNoTestOrgReposForCollaborator", doCheckOrgCounts(orgCollaborator, collabCountRepos, true)) + + t.Run("AddCollboratorToTeam", doAPIAddUserToOrganizationTeam(ctx, testTeam.ID, orgCollaborator)) + + collabCountRepos[orgName] = 0 + t.Run("AssertNumRepos0ForTestOrgForCollaborator", doCheckOrgCounts(orgOwner, ownerCountRepos, true)) + + // Now create a Public Repo + t.Run("CreateOrganizationPublicRepo", doAPICreateOrganizationRepository(ctx, orgName, &api.CreateRepoOption{ + Name: "publicTestRepo", + AutoInit: true, + })) + + ownerCountRepos[orgName] = 2 + t.Run("AssertNumRepos2ForTestOrg", doCheckOrgCounts(orgOwner, ownerCountRepos, true)) + collabCountRepos[orgName] = 1 + t.Run("AssertNumRepos1ForTestOrgForCollaborator", doCheckOrgCounts(orgOwner, ownerCountRepos, true)) + + // Now add the testTeam to the privateRepo + t.Run("AddTestTeamToPrivateRepo", doAPIAddRepoToOrganizationTeam(ctx, testTeam.ID, orgName, "privateTestRepo")) + + t.Run("AssertNumRepos2ForTestOrg", doCheckOrgCounts(orgOwner, ownerCountRepos, true)) + collabCountRepos[orgName] = 2 + t.Run("AssertNumRepos2ForTestOrgForCollaborator", doCheckOrgCounts(orgOwner, ownerCountRepos, true)) +} + +func doCheckOrgCounts(username string, orgCounts map[string]int, strict bool, callback ...func(*testing.T, map[string]int)) func(t *testing.T) { + canonicalCounts := make(map[string]int, len(orgCounts)) + + for key, value := range orgCounts { + newKey := strings.TrimSpace(strings.ToLower(key)) + canonicalCounts[newKey] = value + } + + return func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + Name: username, + }) + + orgs, err := db.Find[organization.Organization](db.DefaultContext, organization.FindOrgOptions{ + UserID: user.ID, + IncludePrivate: true, + }) + require.NoError(t, err) + + calcOrgCounts := map[string]int{} + + for _, org := range orgs { + calcOrgCounts[org.LowerName] = org.NumRepos + count, ok := canonicalCounts[org.LowerName] + if ok { + assert.Equal(t, count, org.NumRepos, "Number of Repos in %s is %d when we expected %d", org.Name, org.NumRepos, count) + } else { + assert.False(t, strict, "Did not expect to see %s with count %d", org.Name, org.NumRepos) + } + } + + for key, value := range orgCounts { + _, seen := calcOrgCounts[strings.TrimSpace(strings.ToLower(key))] + assert.True(t, seen, "Expected to see %s with %d but did not", key, value) + } + + if len(callback) > 0 { + callback[0](t, calcOrgCounts) + } + } +} diff --git a/tests/integration/org_nav_test.go b/tests/integration/org_nav_test.go new file mode 100644 index 0000000..37b6292 --- /dev/null +++ b/tests/integration/org_nav_test.go @@ -0,0 +1,62 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "strings" + "testing" + + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +// This test makes sure that organization members are able to navigate between `/<orgname>` and `/org/<orgname>/<section>` freely. +// The `/org/<orgname>/<section>` page is only accessible to the members of the organization. It doesn't have +// a special logic to show the button or not. +// The `/<orgname>` page utilizes the `IsOrganizationMember` function to show the button for navigation to +// the organization dashboard. That function is covered by a test and is supposed to be true for the +// owners/admins/members of the organization. +func TestOrgNavigationDashboard(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + locale := translation.NewLocale("en-US") + + // Login as the future organization admin and create an organization + session1 := loginUser(t, "user2") + session1.MakeRequest(t, NewRequestWithValues(t, "POST", "/org/create", map[string]string{ + "_csrf": GetCSRF(t, session1, "/org/create"), + "org_name": "org_navigation_test", + "visibility": "0", + "repo_admin_change_team_access": "on", + }), http.StatusSeeOther) + + // Check if the "Open dashboard" button is available to the org admin (member) + resp := session1.MakeRequest(t, NewRequest(t, "GET", "/org_navigation_test"), http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + doc.AssertElement(t, "#org-info a[href='/org/org_navigation_test/dashboard']", true) + + // Verify the "New repository" and "New migration" buttons + links := doc.Find(".organization.profile .grid .column .center") + assert.EqualValues(t, locale.TrString("new_repo.link"), strings.TrimSpace(links.Find("a[href^='/repo/create?org=']").Text())) + assert.EqualValues(t, locale.TrString("new_migrate.link"), strings.TrimSpace(links.Find("a[href^='/repo/migrate?org=']").Text())) + + // Check if the "View <orgname>" button is available on dashboard for the org admin (member) + resp = session1.MakeRequest(t, NewRequest(t, "GET", "/org/org_navigation_test/dashboard"), http.StatusOK) + doc = NewHTMLParser(t, resp.Body) + doc.AssertElement(t, ".dashboard .secondary-nav a[href='/org_navigation_test']", true) + + // Login a non-member user + session2 := loginUser(t, "user4") + + // Check if the "Open dashboard" button is available to non-member + resp = session2.MakeRequest(t, NewRequest(t, "GET", "/org_navigation_test"), http.StatusOK) + doc = NewHTMLParser(t, resp.Body) + doc.AssertElement(t, "#org-info a[href='/org/org_navigation_test/dashboard']", false) + + // There's no need to test "View <orgname>" button on dashboard as non-member + // because this page is not supposed to be visitable for this user +} diff --git a/tests/integration/org_project_test.go b/tests/integration/org_project_test.go new file mode 100644 index 0000000..31d10f1 --- /dev/null +++ b/tests/integration/org_project_test.go @@ -0,0 +1,63 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "slices" + "testing" + + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/tests" +) + +func TestOrgProjectAccess(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + disabledRepoUnits := unit_model.DisabledRepoUnitsGet() + unit_model.DisabledRepoUnitsSet(append(slices.Clone(disabledRepoUnits), unit_model.TypeProjects)) + defer unit_model.DisabledRepoUnitsSet(disabledRepoUnits) + + // repo project, 404 + req := NewRequest(t, "GET", "/user2/repo1/projects") + MakeRequest(t, req, http.StatusNotFound) + + // user project, 200 + req = NewRequest(t, "GET", "/user2/-/projects") + MakeRequest(t, req, http.StatusOK) + + // org project, 200 + req = NewRequest(t, "GET", "/org3/-/projects") + MakeRequest(t, req, http.StatusOK) + + // change the org's visibility to private + session := loginUser(t, "user2") + req = NewRequestWithValues(t, "POST", "/org/org3/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/org3/-/projects"), + "name": "org3", + "visibility": "2", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + // user4 can still access the org's project because its team(team1) has the permission + session = loginUser(t, "user4") + req = NewRequest(t, "GET", "/org3/-/projects") + session.MakeRequest(t, req, http.StatusOK) + + // disable team1's project unit + session = loginUser(t, "user2") + req = NewRequestWithValues(t, "POST", "/org/org3/teams/team1/edit", map[string]string{ + "_csrf": GetCSRF(t, session, "/org3/-/projects"), + "team_name": "team1", + "repo_access": "specific", + "permission": "read", + "unit_8": "0", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + // user4 can no longer access the org's project + session = loginUser(t, "user4") + req = NewRequest(t, "GET", "/org3/-/projects") + session.MakeRequest(t, req, http.StatusNotFound) +} diff --git a/tests/integration/org_team_invite_test.go b/tests/integration/org_team_invite_test.go new file mode 100644 index 0000000..2fe296e --- /dev/null +++ b/tests/integration/org_team_invite_test.go @@ -0,0 +1,382 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "testing" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOrgTeamEmailInvite(t *testing.T) { + if setting.MailService == nil { + t.Skip() + return + } + + defer tests.PrepareTestEnv(t)() + + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + + isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID) + require.NoError(t, err) + assert.False(t, isMember) + + session := loginUser(t, "user1") + + teamURL := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name) + csrf := GetCSRF(t, session, teamURL) + req := NewRequestWithValues(t, "POST", teamURL+"/action/add", map[string]string{ + "_csrf": csrf, + "uid": "1", + "uname": user.Email, + }) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + // get the invite token + invites, err := organization.GetInvitesByTeamID(db.DefaultContext, team.ID) + require.NoError(t, err) + assert.Len(t, invites, 1) + + session = loginUser(t, user.Name) + + // join the team + inviteURL := fmt.Sprintf("/org/invite/%s", invites[0].Token) + csrf = GetCSRF(t, session, inviteURL) + req = NewRequestWithValues(t, "POST", inviteURL, map[string]string{ + "_csrf": csrf, + }) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + isMember, err = organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID) + require.NoError(t, err) + assert.True(t, isMember) +} + +// Check that users are redirected to accept the invitation correctly after login +func TestOrgTeamEmailInviteRedirectsExistingUser(t *testing.T) { + if setting.MailService == nil { + t.Skip() + return + } + + defer tests.PrepareTestEnv(t)() + + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + + isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID) + require.NoError(t, err) + assert.False(t, isMember) + + // create the invite + session := loginUser(t, "user1") + + teamURL := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name) + req := NewRequestWithValues(t, "POST", teamURL+"/action/add", map[string]string{ + "_csrf": GetCSRF(t, session, teamURL), + "uid": "1", + "uname": user.Email, + }) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + // get the invite token + invites, err := organization.GetInvitesByTeamID(db.DefaultContext, team.ID) + require.NoError(t, err) + assert.Len(t, invites, 1) + + // accept the invite + inviteURL := fmt.Sprintf("/org/invite/%s", invites[0].Token) + req = NewRequest(t, "GET", fmt.Sprintf("/user/login?redirect_to=%s", url.QueryEscape(inviteURL))) + resp = MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + req = NewRequestWithValues(t, "POST", "/user/login", map[string]string{ + "_csrf": doc.GetCSRF(), + "user_name": "user5", + "password": "password", + }) + for _, c := range resp.Result().Cookies() { + req.AddCookie(c) + } + + resp = MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, inviteURL, test.RedirectURL(resp)) + + // complete the login process + ch := http.Header{} + ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";")) + cr := http.Request{Header: ch} + + session = emptyTestSession(t) + baseURL, err := url.Parse(setting.AppURL) + require.NoError(t, err) + session.jar.SetCookies(baseURL, cr.Cookies()) + + // make the request + req = NewRequestWithValues(t, "POST", test.RedirectURL(resp), map[string]string{ + "_csrf": GetCSRF(t, session, test.RedirectURL(resp)), + }) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + isMember, err = organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID) + require.NoError(t, err) + assert.True(t, isMember) +} + +// Check that newly signed up users are redirected to accept the invitation correctly +func TestOrgTeamEmailInviteRedirectsNewUser(t *testing.T) { + if setting.MailService == nil { + t.Skip() + return + } + + defer tests.PrepareTestEnv(t)() + + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}) + + // create the invite + session := loginUser(t, "user1") + + teamURL := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name) + req := NewRequestWithValues(t, "POST", teamURL+"/action/add", map[string]string{ + "_csrf": GetCSRF(t, session, teamURL), + "uid": "1", + "uname": "doesnotexist@example.com", + }) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + // get the invite token + invites, err := organization.GetInvitesByTeamID(db.DefaultContext, team.ID) + require.NoError(t, err) + assert.Len(t, invites, 1) + + // accept the invite + inviteURL := fmt.Sprintf("/org/invite/%s", invites[0].Token) + req = NewRequest(t, "GET", fmt.Sprintf("/user/sign_up?redirect_to=%s", url.QueryEscape(inviteURL))) + resp = MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + req = NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{ + "_csrf": doc.GetCSRF(), + "user_name": "doesnotexist", + "email": "doesnotexist@example.com", + "password": "examplePassword!1", + "retype": "examplePassword!1", + }) + for _, c := range resp.Result().Cookies() { + req.AddCookie(c) + } + + resp = MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, inviteURL, test.RedirectURL(resp)) + + // complete the signup process + ch := http.Header{} + ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";")) + cr := http.Request{Header: ch} + + session = emptyTestSession(t) + baseURL, err := url.Parse(setting.AppURL) + require.NoError(t, err) + session.jar.SetCookies(baseURL, cr.Cookies()) + + // make the redirected request + req = NewRequestWithValues(t, "POST", test.RedirectURL(resp), map[string]string{ + "_csrf": GetCSRF(t, session, test.RedirectURL(resp)), + }) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + // get the new user + newUser, err := user_model.GetUserByName(db.DefaultContext, "doesnotexist") + require.NoError(t, err) + + isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, newUser.ID) + require.NoError(t, err) + assert.True(t, isMember) +} + +// Check that users are redirected correctly after confirming their email +func TestOrgTeamEmailInviteRedirectsNewUserWithActivation(t *testing.T) { + if setting.MailService == nil { + t.Skip() + return + } + + // enable email confirmation temporarily + defer func(prevVal bool) { + setting.Service.RegisterEmailConfirm = prevVal + }(setting.Service.RegisterEmailConfirm) + setting.Service.RegisterEmailConfirm = true + + defer tests.PrepareTestEnv(t)() + + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}) + + // create the invite + session := loginUser(t, "user1") + + teamURL := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name) + req := NewRequestWithValues(t, "POST", teamURL+"/action/add", map[string]string{ + "_csrf": GetCSRF(t, session, teamURL), + "uid": "1", + "uname": "doesnotexist@example.com", + }) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + // get the invite token + invites, err := organization.GetInvitesByTeamID(db.DefaultContext, team.ID) + require.NoError(t, err) + assert.Len(t, invites, 1) + + // accept the invite + inviteURL := fmt.Sprintf("/org/invite/%s", invites[0].Token) + req = NewRequest(t, "GET", fmt.Sprintf("/user/sign_up?redirect_to=%s", url.QueryEscape(inviteURL))) + inviteResp := MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + req = NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{ + "_csrf": doc.GetCSRF(), + "user_name": "doesnotexist", + "email": "doesnotexist@example.com", + "password": "examplePassword!1", + "retype": "examplePassword!1", + }) + for _, c := range inviteResp.Result().Cookies() { + req.AddCookie(c) + } + + resp = MakeRequest(t, req, http.StatusOK) + + user, err := user_model.GetUserByName(db.DefaultContext, "doesnotexist") + require.NoError(t, err) + + ch := http.Header{} + ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";")) + cr := http.Request{Header: ch} + + session = emptyTestSession(t) + baseURL, err := url.Parse(setting.AppURL) + require.NoError(t, err) + session.jar.SetCookies(baseURL, cr.Cookies()) + + code, err := user.GenerateEmailAuthorizationCode(db.DefaultContext, auth.UserActivation) + require.NoError(t, err) + + req = NewRequestWithValues(t, "POST", "/user/activate?code="+url.QueryEscape(code), map[string]string{ + "password": "examplePassword!1", + }) + + // use the cookies set by the signup request + for _, c := range inviteResp.Result().Cookies() { + req.AddCookie(c) + } + + resp = session.MakeRequest(t, req, http.StatusSeeOther) + // should be redirected to accept the invite + assert.Equal(t, inviteURL, test.RedirectURL(resp)) + + req = NewRequestWithValues(t, "POST", test.RedirectURL(resp), map[string]string{ + "_csrf": GetCSRF(t, session, test.RedirectURL(resp)), + }) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID) + require.NoError(t, err) + assert.True(t, isMember) +} + +// Test that a logged-in user who navigates to the sign-up link is then redirected using redirect_to +// For example: an invite may have been created before the user account was created, but they may be +// accepting the invite after having created an account separately +func TestOrgTeamEmailInviteRedirectsExistingUserWithLogin(t *testing.T) { + if setting.MailService == nil { + t.Skip() + return + } + + defer tests.PrepareTestEnv(t)() + + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + + isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID) + require.NoError(t, err) + assert.False(t, isMember) + + // create the invite + session := loginUser(t, "user1") + + teamURL := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name) + req := NewRequestWithValues(t, "POST", teamURL+"/action/add", map[string]string{ + "_csrf": GetCSRF(t, session, teamURL), + "uid": "1", + "uname": user.Email, + }) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + // get the invite token + invites, err := organization.GetInvitesByTeamID(db.DefaultContext, team.ID) + require.NoError(t, err) + assert.Len(t, invites, 1) + + // note: the invited user has logged in + session = loginUser(t, "user5") + + // accept the invite (note: this uses the sign_up url) + inviteURL := fmt.Sprintf("/org/invite/%s", invites[0].Token) + req = NewRequest(t, "GET", fmt.Sprintf("/user/sign_up?redirect_to=%s", url.QueryEscape(inviteURL))) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, inviteURL, test.RedirectURL(resp)) + + // make the request + req = NewRequestWithValues(t, "POST", test.RedirectURL(resp), map[string]string{ + "_csrf": GetCSRF(t, session, test.RedirectURL(resp)), + }) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", test.RedirectURL(resp)) + session.MakeRequest(t, req, http.StatusOK) + + isMember, err = organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID) + require.NoError(t, err) + assert.True(t, isMember) +} diff --git a/tests/integration/org_test.go b/tests/integration/org_test.go new file mode 100644 index 0000000..db2e670 --- /dev/null +++ b/tests/integration/org_test.go @@ -0,0 +1,272 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestOrgRepos(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + var ( + users = []string{"user1", "user2"} + cases = map[string][]string{ + "alphabetically": {"repo21", "repo3", "repo5"}, + "recentupdate": {"repo21", "repo5", "repo3"}, + "reversealphabetically": {"repo5", "repo3", "repo21"}, + } + ) + + for _, user := range users { + t.Run(user, func(t *testing.T) { + session := loginUser(t, user) + for sortBy, repos := range cases { + req := NewRequest(t, "GET", "/org3?sort="+sortBy) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + sel := htmlDoc.doc.Find("a.name") + assert.Len(t, repos, len(sel.Nodes)) + for i := 0; i < len(repos); i++ { + assert.EqualValues(t, repos[i], strings.TrimSpace(sel.Eq(i).Text())) + } + } + }) + } +} + +func TestLimitedOrg(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // not logged in user + req := NewRequest(t, "GET", "/limited_org") + MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", "/limited_org/public_repo_on_limited_org") + MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", "/limited_org/private_repo_on_limited_org") + MakeRequest(t, req, http.StatusNotFound) + + // login non-org member user + session := loginUser(t, "user2") + req = NewRequest(t, "GET", "/limited_org") + session.MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", "/limited_org/public_repo_on_limited_org") + session.MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", "/limited_org/private_repo_on_limited_org") + session.MakeRequest(t, req, http.StatusNotFound) + + // site admin + session = loginUser(t, "user1") + req = NewRequest(t, "GET", "/limited_org") + session.MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", "/limited_org/public_repo_on_limited_org") + session.MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", "/limited_org/private_repo_on_limited_org") + session.MakeRequest(t, req, http.StatusOK) +} + +func TestPrivateOrg(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // not logged in user + req := NewRequest(t, "GET", "/privated_org") + MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", "/privated_org/public_repo_on_private_org") + MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", "/privated_org/private_repo_on_private_org") + MakeRequest(t, req, http.StatusNotFound) + + // login non-org member user + session := loginUser(t, "user2") + req = NewRequest(t, "GET", "/privated_org") + session.MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", "/privated_org/public_repo_on_private_org") + session.MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", "/privated_org/private_repo_on_private_org") + session.MakeRequest(t, req, http.StatusNotFound) + + // non-org member who is collaborator on repo in private org + session = loginUser(t, "user4") + req = NewRequest(t, "GET", "/privated_org") + session.MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", "/privated_org/public_repo_on_private_org") // colab of this repo + session.MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", "/privated_org/private_repo_on_private_org") + session.MakeRequest(t, req, http.StatusNotFound) + + // site admin + session = loginUser(t, "user1") + req = NewRequest(t, "GET", "/privated_org") + session.MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", "/privated_org/public_repo_on_private_org") + session.MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", "/privated_org/private_repo_on_private_org") + session.MakeRequest(t, req, http.StatusOK) +} + +func TestOrgMembers(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // not logged in user + req := NewRequest(t, "GET", "/org/org25/members") + MakeRequest(t, req, http.StatusOK) + + // org member + session := loginUser(t, "user24") + req = NewRequest(t, "GET", "/org/org25/members") + session.MakeRequest(t, req, http.StatusOK) + + // site admin + session = loginUser(t, "user1") + req = NewRequest(t, "GET", "/org/org25/members") + session.MakeRequest(t, req, http.StatusOK) +} + +func TestOrgRestrictedUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // privated_org is a private org who has id 23 + orgName := "privated_org" + + // public_repo_on_private_org is a public repo on privated_org + repoName := "public_repo_on_private_org" + + // user29 is a restricted user who is not a member of the organization + restrictedUser := "user29" + + // #17003 reports a bug whereby adding a restricted user to a read-only team doesn't work + + // assert restrictedUser cannot see the org or the public repo + restrictedSession := loginUser(t, restrictedUser) + req := NewRequest(t, "GET", fmt.Sprintf("/%s", orgName)) + restrictedSession.MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s", orgName, repoName)) + restrictedSession.MakeRequest(t, req, http.StatusNotFound) + + // Therefore create a read-only team + adminSession := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeWriteOrganization) + + teamToCreate := &api.CreateTeamOption{ + Name: "codereader", + Description: "Code Reader", + IncludesAllRepositories: true, + Permission: "read", + Units: []string{"repo.code"}, + } + + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", orgName), teamToCreate). + AddTokenAuth(token) + + var apiTeam api.Team + + resp := adminSession.MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, &apiTeam) + checkTeamResponse(t, "CreateTeam_codereader", &apiTeam, teamToCreate.Name, teamToCreate.Description, teamToCreate.IncludesAllRepositories, + teamToCreate.Permission, teamToCreate.Units, nil) + checkTeamBean(t, apiTeam.ID, teamToCreate.Name, teamToCreate.Description, teamToCreate.IncludesAllRepositories, + teamToCreate.Permission, teamToCreate.Units, nil) + // teamID := apiTeam.ID + + // Now we need to add the restricted user to the team + req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/teams/%d/members/%s", apiTeam.ID, restrictedUser)). + AddTokenAuth(token) + _ = adminSession.MakeRequest(t, req, http.StatusNoContent) + + // Now we need to check if the restrictedUser can access the repo + req = NewRequest(t, "GET", fmt.Sprintf("/%s", orgName)) + restrictedSession.MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s", orgName, repoName)) + restrictedSession.MakeRequest(t, req, http.StatusOK) +} + +func TestTeamSearch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15}) + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17}) + + var results TeamSearchResults + + session := loginUser(t, user.Name) + csrf := GetCSRF(t, session, "/"+org.Name) + req := NewRequestf(t, "GET", "/org/%s/teams/-/search?q=%s", org.Name, "_team") + req.Header.Add("X-Csrf-Token", csrf) + resp := session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &results) + assert.NotEmpty(t, results.Data) + assert.Len(t, results.Data, 2) + assert.Equal(t, "review_team", results.Data[0].Name) + assert.Equal(t, "test_team", results.Data[1].Name) + + // no access if not organization member + user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + session = loginUser(t, user5.Name) + csrf = GetCSRF(t, session, "/"+org.Name) + req = NewRequestf(t, "GET", "/org/%s/teams/-/search?q=%s", org.Name, "team") + req.Header.Add("X-Csrf-Token", csrf) + session.MakeRequest(t, req, http.StatusNotFound) +} + +func TestOrgDashboardLabels(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization}) + session := loginUser(t, user.Name) + + req := NewRequestf(t, "GET", "/org/%s/issues?labels=3,4", org.Name) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + labelFilterHref, ok := htmlDoc.Find(".list-header-sort a").Attr("href") + assert.True(t, ok) + assert.Contains(t, labelFilterHref, "labels=3%2c4") + + // Exclude label + req = NewRequestf(t, "GET", "/org/%s/issues?labels=3,-4", org.Name) + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + + labelFilterHref, ok = htmlDoc.Find(".list-header-sort a").Attr("href") + assert.True(t, ok) + assert.Contains(t, labelFilterHref, "labels=3%2c-4") +} + +func TestOwnerTeamUnit(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization}) + session := loginUser(t, user.Name) + + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{TeamID: 1, Type: unit.TypeIssues, AccessMode: perm.AccessModeOwner}) + + req := NewRequestWithValues(t, "GET", fmt.Sprintf("/org/%s/teams/owners/edit", org.Name), map[string]string{ + "_csrf": GetCSRF(t, session, fmt.Sprintf("/org/%s/teams/owners/edit", org.Name)), + "team_name": "Owners", + "Description": "Just a description", + }) + session.MakeRequest(t, req, http.StatusOK) + + unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{TeamID: 1, Type: unit.TypeIssues, AccessMode: perm.AccessModeOwner}) +} diff --git a/tests/integration/private-testing.key b/tests/integration/private-testing.key new file mode 100644 index 0000000..b3874ea --- /dev/null +++ b/tests/integration/private-testing.key @@ -0,0 +1,81 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- + +lQVYBGG44vABDAC7VVdrVcU2CzI4P1vm0HtsgRCj9TsCpxjESleIheG/jrLjpVaF +YrlVKQ0+q6HXOMcbjJnsm+N6hgZNqwaKTNC6+LJZMXHlPG8wUGrHgHyUZ03urYB6 +vjlJ70RUBu1+dB5yJcTOk7kMLx8/is9FlAEEY/G98aviv2m3My6B5SJ2BErjREIw +eRnWFm+JDcga9nRi8ra/DMac45iQ4IcQcj0NDlCn3aY88nGa6o1+07h7wYwI3t8S ++pfITuJgWf2cYK49v9QVsBMR8XHuS8UDGFuJ1Y4KK5zMHWKhah/6isyWPSgiC0wo +V7LZDJp/tN8IoQf2fchRQN+x0PBeVXdt3KGXqvsfk7hnwGDKjGMp4nTxL8PFhpG8 +KJP0tTA063bbnrGjVYHaulTBTSKS8R3Zk2utA8JUgTU6tkNFoh8rNLgh2xtw/Ci3 +kvKzTdikWxBfspYgrWloMyCTZwOHssARyarXgtysEI1hNpvgpJo0WZOMurYuFDIB +kEqgnqe1b1B7ItcAEQEAAQAL/iNebgZkZ7sX6w/mmn3eL+dhCNjD5LPQA6OP2635 +hRFLKmhDn63IYXB8MzV5ZzGA1UrUxX0AQ7cu1cLVPwNelGwwp0+iv7vFqMKI9Fgd +YKgORw8AsAi8oIlehNqOgkmFN/haPCm6h04PGYnANfkPhA+lpQ81MTw64oVFwwqg +TdzVW6RED3EidCfRDZblRLoefQPvimRQz7DwYa48zhNjVjaAVOcUuJ26MovKrBNd +eu/Wr48/MQPez0hw6FnDs9fSAtB/cLmSlSL3yBkDB4RHTne6amvemX5SyQqOSKLJ +F+YM33yIN3NQNQtJUkjNkBWuIe+s8pxFuKTHNyulCe/ES0ivtnqaCJ/J/PPzn/3t +2S5f1K26jqJEnu4SfCxG3xTbSMu9DIcDP6BkU6WK9dQCPyfWZ3r3QkgZjHt02HP9 +Gbzh2tSxBO3b4ujysdSB2l78I0s3XLWae6FPNNKG+zmlCV8mUEa+OFVjS60GrX83 +NQVfoyjNdSQkLlg3+bo5DFma+QYAwr/HXi06iC8dh23HkPkYedIOml70SPAQqvVj +xYtZRRSXo98P+QtA2kX0G3/9f606n2qqA9JXc3m4euvE94oSp708M5xAkSfdsc6B +QIDNrR5ty+f+WdhZAsW4Gu/XbQ5ndkRReTtc3UtzIrC0zg8egCoE0yMfCJWPS2nF +QTdlsl+cXDSQj7UMfCP9cKSsTzdEAF/P5ALI7Y+W4va/gy/0czJne+ZNMxPWE+Gs +00KJCbSfgktnYhVt/XdWKuRZ8ylZBgD2QHts6MHkfno/OUK3wYDB7zLMIBdLltlg +wvp7CXh8hIxzNqxaAjGus1XAg+/7QbSey/t88CR9XQsekd/L8NIYaFOxSpVAe03V +RaW2/EXtmKIHKoWBTQJLJle3mp+iUiVjzdmTyUAqhFaCBYVMBlSvBuC99jXnu3U3 +UcUelLDvP2ufMdeXhVU1Anfg45wqvyfPIAhpgYMmyprGpfkd2Sf2W1ThaTec0kI1 +cT7AtkrqijCGDgo9ohl8ojmRhRCl968F/imENQATANdkhbYJ0k1+Ubm690xYNN7u +d+wnQzS9P/UPpMrC4H2esz9g+Nls7X6/jeGB6K0bpOYAUR1VlRfuXREJcy9bK9Q8 +gzfBC4XWELA726fc9YeJqWH4fI9SFx0AjVVx6VFwSiDcoYbX26CLZN+jY6Gx8kx6 +PrOf4tPCU+8EP5f/tYn/dwN9oQPoyM7bYyN/zcrupLhHON7ryFr++Kpiw0feBGbg +kEP+0HWJ2cX1MvcqTurx344RVlmnEBesDuFstBhnaXRlYSA8Z2l0ZWFAZmFrZS5s +b2NhbD6JAc4EEwEKADgWIQT3rIVBIbYw8mUW1p+Z3Yqpy9FcAQUCYbji8AIbAwUL +CQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRCZ3Yqpy9FcAY6AC/9GUc0vGAmZ1N7P +ThOxy3SvoIWJzycEu6DKdp4FlucKW9Rm66vCwPDg7XcQxZQTIWNPIGB3kln0yRdx +zRtGLKIDPo2qW8kPrLN3GXToKX2mBb76duaShW34W1rUVY613olmtwLT+QqgRX+H +x0rNNJloOh3kawwaMoYZy4B2vq7AZ5ybIsT4ROKgKPzAlajI4+jI+qKA5GSyP7Jq +Tu254BCeg0v51p0VWIbGdgPyVkZkLtrlxN7s8UGDoTUAJgB/K3SOGNtQFSxnJba5 +q0YBxDUScd65b1+YCUHY+3FdC4/5168y4Zic9bBxeVu3jBwSVDvrELsWzIDNgHmP +eyl/Nv+CTZDDKOtzpS823k7gC129rcxMk0mkIzAt/wG7N4zf0vpt02LZ/Ei/azqK +xq782Fmc3un+pgQWJrlU2ZT7yHi6aJAfxfDpQZwz8qXGgdaFsumNylEWy9o80pJG +8RYgM+phZL4INYIiHoWUuz2v+qK9jmhxtTLOpKDXxtGrz6aFWJadBVgEYbji8AEM +AMDFivCjl7vGACeST4iboZw817uAJFOTOk3uOnXuAx5NLq/DbL3Cyhjictwxhxot +U1MdAZOSOHlWPBJTiib1145rDTJCH6gwQNVaqn/V0i/Dc2Isua4YF0efztzwD2aH +NX4RCDp74bQ08YTsAlCWHk7blg3NCU/y4maaxdJ26PsNrIiY0l5SC3oNiEAp2aWP +Yf+plmQwqk+Z3laB5fkVz8Vca8TZle11/NZVVwrpq8rubPUYHC2KmabFLihcMCGv +eTt3LCB7tDzohDmX/0vuqTD09YTv5gmIzU/tx4+qH4tVfCK/DKTxsxafY4KZY4kM +hqrhuGWq8EAu4RUG6AzbSDJZnO1UAfzC9j/8upr3qxOXx/xhWKzixGrRXo9eK5eR +1pqEj+XGH+f9bQiF/pEIojcUp45S0ZBaSBPj2W7TZbbHzqXYNzmXa3IVdz+9l7MB +cRfIe67wt4h66/fmATe47KvHNRfKpyhFD2utdOSd61tKXo/bu/5LBath0mxMBPHd +4QARAQABAAv5Aacf824U/LiW+JU4poVJFofEr22gQhwwIt9rnmZm80ak+L+o9MaR +CN4WLzJN2X5b1B8FTAXerexR8bPy1QsvaN/yRMT23wW3j0IVVf5tbIM/6m6o5+fP +zp7S5/zh8OvbXE7v6Qp2C19sgQqB/ugOmff9hSBF18A6II2Wq8uLtgKua5xof1kI +5/1qNpH1SltcndPPKjbq8D7zk6kjoZCw5PJk1ShVcKwIjzDmS729qezZ6nm6sh7v +BX70JUdHErQzBtcb+Y39nRC/7aQ/X5s73Iy9OsnAzzTSTtw1RgxgAYXxQKhQN5xP +rzUdZqCSFicjLAPvY4PxQmIL+DS7tb/rrWUJAfr/9LcrzoOC5LaYFTuykq231ORs +4oRfHmJqYAiMYQ7iXMtFVspxQWq/8qrBPmmEkS2oAnmd8Ld5hbd7sFBsS5GCW9a3 +UyQQ9WQECyvpgFOR9m746/bFjKMgG+aBHyKvndniF3XWjHWrzrbk5vAViMb+9Al+ +7MxSqZ/oNrvdBgDT6hTMwyNBvQwJ/Lev0S3XPDJmxg+Y8QIrNbBrXjA70yVeLFgr +emDnfdAwuhmZ5vKRe2YcIyMIOagRIDUEWs8EyCvM2e+bF+I0meQvWT536Cm2TouI +jCUIip4HRTwe7NAR50OMACtji8sbcmfnIfFMfGUS3dPpNGURhCEHxWB6hlvbkbkV +CToTlMS/agY0sV4O4kWqWiaKgZRefJSiVfj6RDKs43SbNxhJu+DslU7PPlfv6SFJ +nX9LWE6daLrpuF0GAOjf+kjqpFFgF50h3B7lCsSfxIKW587z93rkmccGKvZj4Qeq +ahjekO6kxapYJhtjY9BOQdU0rzEPhh8bF39GE/iCfXVdIh1suqp3uQv9birgkWJN +CROrHvk5NmlBBb4BDid0hY8hM3lEi+6rK2lhs4krpoHin/h852AI+YBzeAVYSqor +fqEzCiPlX7f1EI3I6kPnGrgeIWcznOO0yXkM/QuKCDWZlaLDxu7Rc5lBnsmiChrT +3HwOiyOFfU1Rib/TVQYAng1PxHZfIfC77cblAiv3SXjFtSDIfyueER3Ii11DyEfB +zco+qbpqYiDEI7yLZFuyExEpT2GbHTTEn28aEZzZBv/aFRnVFPTMiyquFE7QKuLc +aEpEYZE3qSiAUDAckfDblM1SHZAVP6CaStkoUigtYBND2F316MTNGGLtcJ4y9s1r +soqvCJ/cx0lR359kljqCHyv+iMqeBttwTGjFbiNJ5as4ATA988FlR6PnB0cr+Lg2 +8X3xiRcAaxlLFcUOifpa3m6JAbYEGAEKACAWIQT3rIVBIbYw8mUW1p+Z3Yqpy9Fc +AQUCYbji8AIbDAAKCRCZ3Yqpy9FcAT/pDACilZ8zPUs+MwwI0BI6dMWxmhusHwTx +kdwbxt2TuCQE3DEftCTCaxO5f8hQ6CL9pxYw5mn/6p8ELUpindFxgzpBjUQZyynb ++ZA7LOK5gKw25vGTRcMFiWZOBnMEAifyywmG6XCPtio8i3/In95ix/Adi17tzdpy +EfFfWTeDocTNPhIPhg9REteZ71eBW3qEbY2iCeG3XSpKhkj6obY7BL8xLT9iaezh +C6Upzb3gvjEInoaMR2yra9fVugW32lCFgXr6UZ5osBqVNjXGcwBqxg5IAkt4R5v1 +vdt5h69cagkbdS0qSRbS56GctmxVnbWyuAuKON55BDri5BhO3V4GmIXXUW12dQhl +1/P9+xMjHm424QlGL7jgEzOMR5CJFdDQ+osabA2iZAUEQ7Ut8SgREfCduqKqzJ7z +Uvb3feuoW45VNBqv7op8hH1S8okFaCTuznrAPqGXxee0I3oTX1lbBW+IySoisWMC +ZMtt+nu5oJo/m1bvhWiYLhW6WX8TcmRKD3s= +=V9rS +-----END PGP PRIVATE KEY BLOCK----- diff --git a/tests/integration/privateactivity_test.go b/tests/integration/privateactivity_test.go new file mode 100644 index 0000000..5362462 --- /dev/null +++ b/tests/integration/privateactivity_test.go @@ -0,0 +1,418 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + activities_model "code.gitea.io/gitea/models/activities" + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +const ( + privateActivityTestAdmin = "user1" + privateActivityTestUser = "user2" +) + +// org3 is an organization so it is not usable here +const privateActivityTestOtherUser = "user4" + +// activity helpers + +func testPrivateActivityDoSomethingForActionEntries(t *testing.T) { + repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID}) + + session := loginUser(t, privateActivityTestUser) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues?state=all", owner.Name, repoBefore.Name) + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{ + Body: "test", + Title: "test", + }).AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusCreated) +} + +// private activity helpers + +func testPrivateActivityHelperEnablePrivateActivity(t *testing.T) { + session := loginUser(t, privateActivityTestUser) + req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings"), + "name": privateActivityTestUser, + "email": privateActivityTestUser + "@example.com", + "language": "en-US", + "keep_activity_private": "1", + }) + session.MakeRequest(t, req, http.StatusSeeOther) +} + +func testPrivateActivityHelperHasVisibleActivitiesInHTMLDoc(htmlDoc *HTMLDoc) bool { + return htmlDoc.doc.Find("#activity-feed").Find(".flex-item").Length() > 0 +} + +func testPrivateActivityHelperHasVisibleActivitiesFromSession(t *testing.T, session *TestSession) bool { + req := NewRequestf(t, "GET", "/%s?tab=activity", privateActivityTestUser) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + return testPrivateActivityHelperHasVisibleActivitiesInHTMLDoc(htmlDoc) +} + +func testPrivateActivityHelperHasVisibleActivitiesFromPublic(t *testing.T) bool { + req := NewRequestf(t, "GET", "/%s?tab=activity", privateActivityTestUser) + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + return testPrivateActivityHelperHasVisibleActivitiesInHTMLDoc(htmlDoc) +} + +// heatmap UI helpers + +func testPrivateActivityHelperHasVisibleHeatmapInHTMLDoc(htmlDoc *HTMLDoc) bool { + return htmlDoc.doc.Find("#user-heatmap").Length() > 0 +} + +func testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t *testing.T, session *TestSession) bool { + req := NewRequestf(t, "GET", "/%s?tab=activity", privateActivityTestUser) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + return testPrivateActivityHelperHasVisibleHeatmapInHTMLDoc(htmlDoc) +} + +func testPrivateActivityHelperHasVisibleDashboardHeatmapFromSession(t *testing.T, session *TestSession) bool { + req := NewRequest(t, "GET", "/") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + return testPrivateActivityHelperHasVisibleHeatmapInHTMLDoc(htmlDoc) +} + +func testPrivateActivityHelperHasVisibleHeatmapFromPublic(t *testing.T) bool { + req := NewRequestf(t, "GET", "/%s?tab=activity", privateActivityTestUser) + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + return testPrivateActivityHelperHasVisibleHeatmapInHTMLDoc(htmlDoc) +} + +// heatmap API helpers + +func testPrivateActivityHelperHasHeatmapContentFromPublic(t *testing.T) bool { + req := NewRequestf(t, "GET", "/api/v1/users/%s/heatmap", privateActivityTestUser) + resp := MakeRequest(t, req, http.StatusOK) + + var items []*activities_model.UserHeatmapData + DecodeJSON(t, resp, &items) + + return len(items) != 0 +} + +func testPrivateActivityHelperHasHeatmapContentFromSession(t *testing.T, session *TestSession) bool { + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser) + + req := NewRequestf(t, "GET", "/api/v1/users/%s/heatmap", privateActivityTestUser). + AddTokenAuth(token) + resp := session.MakeRequest(t, req, http.StatusOK) + + var items []*activities_model.UserHeatmapData + DecodeJSON(t, resp, &items) + + return len(items) != 0 +} + +// check activity visibility if the visibility is enabled + +func TestPrivateActivityNoVisibleForPublic(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + visible := testPrivateActivityHelperHasVisibleActivitiesFromPublic(t) + + assert.True(t, visible, "user should have visible activities") +} + +func TestPrivateActivityNoVisibleForUserItself(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestUser) + visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session) + + assert.True(t, visible, "user should have visible activities") +} + +func TestPrivateActivityNoVisibleForOtherUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestOtherUser) + visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session) + + assert.True(t, visible, "user should have visible activities") +} + +func TestPrivateActivityNoVisibleForAdmin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestAdmin) + visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session) + + assert.True(t, visible, "user should have visible activities") +} + +// check activity visibility if the visibility is disabled + +func TestPrivateActivityYesInvisibleForPublic(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + visible := testPrivateActivityHelperHasVisibleActivitiesFromPublic(t) + + assert.False(t, visible, "user should have no visible activities") +} + +func TestPrivateActivityYesVisibleForUserItself(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestUser) + visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session) + + assert.True(t, visible, "user should have visible activities") +} + +func TestPrivateActivityYesInvisibleForOtherUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestOtherUser) + visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session) + + assert.False(t, visible, "user should have no visible activities") +} + +func TestPrivateActivityYesVisibleForAdmin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestAdmin) + visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session) + + assert.True(t, visible, "user should have visible activities") +} + +// check heatmap visibility if the visibility is enabled + +func TestPrivateActivityNoHeatmapVisibleForPublic(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + visible := testPrivateActivityHelperHasVisibleHeatmapFromPublic(t) + + assert.True(t, visible, "user should have visible heatmap") +} + +func TestPrivateActivityNoHeatmapVisibleForUserItselfAtProfile(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestUser) + visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session) + + assert.True(t, visible, "user should have visible heatmap") +} + +func TestPrivateActivityNoHeatmapVisibleForUserItselfAtDashboard(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestUser) + visible := testPrivateActivityHelperHasVisibleDashboardHeatmapFromSession(t, session) + + assert.True(t, visible, "user should have visible heatmap") +} + +func TestPrivateActivityNoHeatmapVisibleForOtherUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestOtherUser) + visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session) + + assert.True(t, visible, "user should have visible heatmap") +} + +func TestPrivateActivityNoHeatmapVisibleForAdmin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestAdmin) + visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session) + + assert.True(t, visible, "user should have visible heatmap") +} + +// check heatmap visibility if the visibility is disabled + +func TestPrivateActivityYesHeatmapInvisibleForPublic(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + visible := testPrivateActivityHelperHasVisibleHeatmapFromPublic(t) + + assert.False(t, visible, "user should have no visible heatmap") +} + +func TestPrivateActivityYesHeatmapVisibleForUserItselfAtProfile(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestUser) + visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session) + + assert.True(t, visible, "user should have visible heatmap") +} + +func TestPrivateActivityYesHeatmapVisibleForUserItselfAtDashboard(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestUser) + visible := testPrivateActivityHelperHasVisibleDashboardHeatmapFromSession(t, session) + + assert.True(t, visible, "user should have visible heatmap") +} + +func TestPrivateActivityYesHeatmapInvisibleForOtherUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestOtherUser) + visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session) + + assert.False(t, visible, "user should have no visible heatmap") +} + +func TestPrivateActivityYesHeatmapVisibleForAdmin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestAdmin) + visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session) + + assert.True(t, visible, "user should have visible heatmap") +} + +// check heatmap api provides content if the visibility is enabled + +func TestPrivateActivityNoHeatmapHasContentForPublic(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + hasContent := testPrivateActivityHelperHasHeatmapContentFromPublic(t) + + assert.True(t, hasContent, "user should have heatmap content") +} + +func TestPrivateActivityNoHeatmapHasContentForUserItself(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestUser) + hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session) + + assert.True(t, hasContent, "user should have heatmap content") +} + +func TestPrivateActivityNoHeatmapHasContentForOtherUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestOtherUser) + hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session) + + assert.True(t, hasContent, "user should have heatmap content") +} + +func TestPrivateActivityNoHeatmapHasContentForAdmin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + + session := loginUser(t, privateActivityTestAdmin) + hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session) + + assert.True(t, hasContent, "user should have heatmap content") +} + +// check heatmap api provides no content if the visibility is disabled +// this should be equal to the hidden heatmap at the UI + +func TestPrivateActivityYesHeatmapHasNoContentForPublic(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + hasContent := testPrivateActivityHelperHasHeatmapContentFromPublic(t) + + assert.False(t, hasContent, "user should have no heatmap content") +} + +func TestPrivateActivityYesHeatmapHasNoContentForUserItself(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestUser) + hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session) + + assert.True(t, hasContent, "user should see their own heatmap content") +} + +func TestPrivateActivityYesHeatmapHasNoContentForOtherUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestOtherUser) + hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session) + + assert.False(t, hasContent, "other user should not see heatmap content") +} + +func TestPrivateActivityYesHeatmapHasNoContentForAdmin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testPrivateActivityDoSomethingForActionEntries(t) + testPrivateActivityHelperEnablePrivateActivity(t) + + session := loginUser(t, privateActivityTestAdmin) + hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session) + + assert.True(t, hasContent, "heatmap should show content for admin") +} diff --git a/tests/integration/proctected_branch_test.go b/tests/integration/proctected_branch_test.go new file mode 100644 index 0000000..9c6e5e3 --- /dev/null +++ b/tests/integration/proctected_branch_test.go @@ -0,0 +1,87 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "strconv" + "strings" + "testing" + + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestProtectedBranch_AdminEnforcement(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFileToNewBranch(t, session, "user1", "repo1", "master", "add-readme", "README.md", "WIP") + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: 1, Name: "repo1"}) + + req := NewRequestWithValues(t, "POST", "user1/repo1/compare/master...add-readme", map[string]string{ + "_csrf": GetCSRF(t, session, "user1/repo1/compare/master...add-readme"), + "title": "pull request", + }) + session.MakeRequest(t, req, http.StatusOK) + + t.Run("No protected branch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req = NewRequest(t, "GET", "/user1/repo1/pulls/1") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + text := strings.TrimSpace(doc.doc.Find(".merge-section").Text()) + assert.Contains(t, text, "This pull request can be merged automatically.") + assert.Contains(t, text, "'canMergeNow': true") + }) + + t.Run("Without admin enforcement", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user1/repo1/settings/branches/edit", map[string]string{ + "_csrf": GetCSRF(t, session, "/user1/repo1/settings/branches/edit"), + "rule_name": "master", + "required_approvals": "1", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + req = NewRequest(t, "GET", "/user1/repo1/pulls/1") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + text := strings.TrimSpace(doc.doc.Find(".merge-section").Text()) + assert.Contains(t, text, "This pull request doesn't have enough approvals yet. 0 of 1 approvals granted.") + assert.Contains(t, text, "'canMergeNow': true") + }) + + t.Run("With admin enforcement", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + protectedBranch := unittest.AssertExistsAndLoadBean(t, &git_model.ProtectedBranch{RuleName: "master", RepoID: repo.ID}) + req := NewRequestWithValues(t, "POST", "/user1/repo1/settings/branches/edit", map[string]string{ + "_csrf": GetCSRF(t, session, "/user1/repo1/settings/branches/edit"), + "rule_name": "master", + "rule_id": strconv.FormatInt(protectedBranch.ID, 10), + "required_approvals": "1", + "apply_to_admins": "true", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + req = NewRequest(t, "GET", "/user1/repo1/pulls/1") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + text := strings.TrimSpace(doc.doc.Find(".merge-section").Text()) + assert.Contains(t, text, "This pull request doesn't have enough approvals yet. 0 of 1 approvals granted.") + assert.Contains(t, text, "'canMergeNow': false") + }) + }) +} diff --git a/tests/integration/project_test.go b/tests/integration/project_test.go new file mode 100644 index 0000000..fc2986e --- /dev/null +++ b/tests/integration/project_test.go @@ -0,0 +1,84 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + "code.gitea.io/gitea/models/db" + project_model "code.gitea.io/gitea/models/project" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPrivateRepoProject(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // not logged in user + req := NewRequest(t, "GET", "/user31/-/projects") + MakeRequest(t, req, http.StatusNotFound) + + sess := loginUser(t, "user1") + req = NewRequest(t, "GET", "/user31/-/projects") + sess.MakeRequest(t, req, http.StatusOK) +} + +func TestMoveRepoProjectColumns(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + + project1 := project_model.Project{ + Title: "new created project", + RepoID: repo2.ID, + Type: project_model.TypeRepository, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(db.DefaultContext, &project1) + require.NoError(t, err) + + for i := 0; i < 3; i++ { + err = project_model.NewColumn(db.DefaultContext, &project_model.Column{ + Title: fmt.Sprintf("column %d", i+1), + ProjectID: project1.ID, + }) + require.NoError(t, err) + } + + columns, err := project1.GetColumns(db.DefaultContext) + require.NoError(t, err) + assert.Len(t, columns, 3) + assert.EqualValues(t, 0, columns[0].Sorting) + assert.EqualValues(t, 1, columns[1].Sorting) + assert.EqualValues(t, 2, columns[2].Sorting) + + sess := loginUser(t, "user1") + req := NewRequest(t, "GET", fmt.Sprintf("/%s/projects/%d", repo2.FullName(), project1.ID)) + resp := sess.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/projects/%d/move?_csrf="+htmlDoc.GetCSRF(), repo2.FullName(), project1.ID), map[string]any{ + "columns": []map[string]any{ + {"columnID": columns[1].ID, "sorting": 0}, + {"columnID": columns[2].ID, "sorting": 1}, + {"columnID": columns[0].ID, "sorting": 2}, + }, + }) + sess.MakeRequest(t, req, http.StatusOK) + + columnsAfter, err := project1.GetColumns(db.DefaultContext) + require.NoError(t, err) + assert.Len(t, columns, 3) + assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID) + assert.EqualValues(t, columns[2].ID, columnsAfter[1].ID) + assert.EqualValues(t, columns[0].ID, columnsAfter[2].ID) + + require.NoError(t, project_model.DeleteProjectByID(db.DefaultContext, project1.ID)) +} diff --git a/tests/integration/pull_commit_test.go b/tests/integration/pull_commit_test.go new file mode 100644 index 0000000..477f017 --- /dev/null +++ b/tests/integration/pull_commit_test.go @@ -0,0 +1,34 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "testing" + + pull_service "code.gitea.io/gitea/services/pull" + + "github.com/stretchr/testify/assert" +) + +func TestListPullCommits(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user5") + req := NewRequest(t, "GET", "/user2/repo1/pulls/3/commits/list") + resp := session.MakeRequest(t, req, http.StatusOK) + + var pullCommitList struct { + Commits []pull_service.CommitInfo `json:"commits"` + LastReviewCommitSha string `json:"last_review_commit_sha"` + } + DecodeJSON(t, resp, &pullCommitList) + + if assert.Len(t, pullCommitList.Commits, 2) { + assert.Equal(t, "5f22f7d0d95d614d25a5b68592adb345a4b5c7fd", pullCommitList.Commits[0].ID) + assert.Equal(t, "4a357436d925b5c974181ff12a994538ddc5a269", pullCommitList.Commits[1].ID) + } + assert.Equal(t, "4a357436d925b5c974181ff12a994538ddc5a269", pullCommitList.LastReviewCommitSha) + }) +} diff --git a/tests/integration/pull_compare_test.go b/tests/integration/pull_compare_test.go new file mode 100644 index 0000000..f5baf05 --- /dev/null +++ b/tests/integration/pull_compare_test.go @@ -0,0 +1,28 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestPullCompare(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user2/repo1/pulls") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + link, exists := htmlDoc.doc.Find(".new-pr-button").Attr("href") + assert.True(t, exists, "The template has changed") + + req = NewRequest(t, "GET", link) + resp = session.MakeRequest(t, req, http.StatusOK) + assert.EqualValues(t, http.StatusOK, resp.Code) +} diff --git a/tests/integration/pull_create_test.go b/tests/integration/pull_create_test.go new file mode 100644 index 0000000..b814642 --- /dev/null +++ b/tests/integration/pull_create_test.go @@ -0,0 +1,558 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "path" + "regexp" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/test" + repo_service "code.gitea.io/gitea/services/repository" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testPullCreate(t *testing.T, session *TestSession, user, repo string, toSelf bool, targetBranch, sourceBranch, title string) *httptest.ResponseRecorder { + req := NewRequest(t, "GET", path.Join(user, repo)) + resp := session.MakeRequest(t, req, http.StatusOK) + + // Click the PR button to create a pull + htmlDoc := NewHTMLParser(t, resp.Body) + link, exists := htmlDoc.doc.Find("#new-pull-request").Attr("href") + assert.True(t, exists, "The template has changed") + + targetUser := strings.Split(link, "/")[1] + if toSelf && targetUser != user { + link = strings.Replace(link, targetUser, user, 1) + } + + // get main out of /user/project/main...some:other/branch + defaultBranch := regexp.MustCompile(`^.*/(.*)\.\.\.`).FindStringSubmatch(link)[1] + if targetBranch != defaultBranch { + link = strings.Replace(link, defaultBranch+"...", targetBranch+"...", 1) + } + if sourceBranch != defaultBranch { + if targetUser == user { + link = strings.Replace(link, "..."+defaultBranch, "..."+sourceBranch, 1) + } else { + link = strings.Replace(link, ":"+defaultBranch, ":"+sourceBranch, 1) + } + } + + req = NewRequest(t, "GET", link) + resp = session.MakeRequest(t, req, http.StatusOK) + + // Submit the form for creating the pull + htmlDoc = NewHTMLParser(t, resp.Body) + link, exists = htmlDoc.doc.Find("form.ui.form").Attr("action") + assert.True(t, exists, "The template has changed") + req = NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "title": title, + }) + resp = session.MakeRequest(t, req, http.StatusOK) + return resp +} + +func testPullCreateDirectly(t *testing.T, session *TestSession, baseRepoOwner, baseRepoName, baseBranch, headRepoOwner, headRepoName, headBranch, title string) *httptest.ResponseRecorder { + headCompare := headBranch + if headRepoOwner != "" { + if headRepoName != "" { + headCompare = fmt.Sprintf("%s/%s:%s", headRepoOwner, headRepoName, headBranch) + } else { + headCompare = fmt.Sprintf("%s:%s", headRepoOwner, headBranch) + } + } + req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/compare/%s...%s", baseRepoOwner, baseRepoName, baseBranch, headCompare)) + resp := session.MakeRequest(t, req, http.StatusOK) + + // Submit the form for creating the pull + htmlDoc := NewHTMLParser(t, resp.Body) + link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action") + assert.True(t, exists, "The template has changed") + req = NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "title": title, + }) + resp = session.MakeRequest(t, req, http.StatusOK) + return resp +} + +func TestPullCreate(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n") + resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "This is a pull title") + + // check the redirected URL + url := test.RedirectURL(resp) + assert.Regexp(t, "^/user2/repo1/pulls/[0-9]*$", url) + + // check .diff can be accessed and matches performed change + req := NewRequest(t, "GET", url+".diff") + resp = session.MakeRequest(t, req, http.StatusOK) + assert.Regexp(t, `\+Hello, World \(Edited\)`, resp.Body) + assert.Regexp(t, "^diff", resp.Body) + assert.NotRegexp(t, "diff.*diff", resp.Body) // not two diffs, just one + + // check .patch can be accessed and matches performed change + req = NewRequest(t, "GET", url+".patch") + resp = session.MakeRequest(t, req, http.StatusOK) + assert.Regexp(t, `\+Hello, World \(Edited\)`, resp.Body) + assert.Regexp(t, "diff", resp.Body) + assert.Regexp(t, `Subject: \[PATCH\] Update README.md`, resp.Body) + assert.NotRegexp(t, "diff.*diff", resp.Body) // not two diffs, just one + }) +} + +func TestPullCreateWithPullTemplate(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + baseUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + forkUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + templateCandidates := []string{ + ".forgejo/PULL_REQUEST_TEMPLATE.md", + ".forgejo/pull_request_template.md", + ".gitea/PULL_REQUEST_TEMPLATE.md", + ".gitea/pull_request_template.md", + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/pull_request_template.md", + } + + createBaseRepo := func(t *testing.T, templateFiles []string, message string) (*repo_model.Repository, func()) { + t.Helper() + + changeOps := make([]*files_service.ChangeRepoFile, len(templateFiles)) + for i, template := range templateFiles { + changeOps[i] = &files_service.ChangeRepoFile{ + Operation: "create", + TreePath: template, + ContentReader: strings.NewReader(message + " " + template), + } + } + + repo, _, deferrer := tests.CreateDeclarativeRepo(t, baseUser, "", nil, nil, changeOps) + + return repo, deferrer + } + + testPullPreview := func(t *testing.T, session *TestSession, user, repo, message string) { + t.Helper() + + req := NewRequest(t, "GET", path.Join(user, repo)) + resp := session.MakeRequest(t, req, http.StatusOK) + + // Click the PR button to create a pull + htmlDoc := NewHTMLParser(t, resp.Body) + link, exists := htmlDoc.doc.Find("#new-pull-request").Attr("href") + assert.True(t, exists, "The template has changed") + + // Load the pull request preview + req = NewRequest(t, "GET", link) + resp = session.MakeRequest(t, req, http.StatusOK) + + // Check that the message from the template is present. + htmlDoc = NewHTMLParser(t, resp.Body) + pullRequestMessage := htmlDoc.doc.Find("textarea[placeholder*='comment']").Text() + assert.Equal(t, message, pullRequestMessage) + } + + for i, template := range templateCandidates { + t.Run(template, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create the base repository, with the pull request template added. + message := fmt.Sprintf("TestPullCreateWithPullTemplate/%s", template) + baseRepo, deferrer := createBaseRepo(t, []string{template}, message) + defer deferrer() + + // Fork the repository + session := loginUser(t, forkUser.Name) + testRepoFork(t, session, baseUser.Name, baseRepo.Name, forkUser.Name, baseRepo.Name) + forkedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: forkUser.ID, Name: baseRepo.Name}) + + // Apply a change to the fork + err := createOrReplaceFileInBranch(forkUser, forkedRepo, "README.md", forkedRepo.DefaultBranch, fmt.Sprintf("Hello, World (%d)\n", i)) + require.NoError(t, err) + + testPullPreview(t, session, forkUser.Name, forkedRepo.Name, message+" "+template) + }) + } + + t.Run("multiple template options", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create the base repository, with the pull request template added. + message := "TestPullCreateWithPullTemplate/multiple" + baseRepo, deferrer := createBaseRepo(t, templateCandidates, message) + defer deferrer() + + // Fork the repository + session := loginUser(t, forkUser.Name) + testRepoFork(t, session, baseUser.Name, baseRepo.Name, forkUser.Name, baseRepo.Name) + forkedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: forkUser.ID, Name: baseRepo.Name}) + + // Apply a change to the fork + err := createOrReplaceFileInBranch(forkUser, forkedRepo, "README.md", forkedRepo.DefaultBranch, "Hello, World (%d)\n") + require.NoError(t, err) + + // Unlike issues, where all candidates are considered and shown, for + // pull request, there's a priority: if there are multiple + // templates, only the highest priority one is used. + testPullPreview(t, session, forkUser.Name, forkedRepo.Name, message+" .forgejo/PULL_REQUEST_TEMPLATE.md") + }) + }) +} + +func TestPullCreate_TitleEscape(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n") + resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "<i>XSS PR</i>") + + // check the redirected URL + url := test.RedirectURL(resp) + assert.Regexp(t, "^/user2/repo1/pulls/[0-9]*$", url) + + // Edit title + req := NewRequest(t, "GET", url) + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + editTestTitleURL, exists := htmlDoc.doc.Find(".button-row button[data-update-url]").First().Attr("data-update-url") + assert.True(t, exists, "The template has changed") + + req = NewRequestWithValues(t, "POST", editTestTitleURL, map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "title": "<u>XSS PR</u>", + }) + session.MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", url) + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + titleHTML, err := htmlDoc.doc.Find(".comment-list .timeline-item.event .text b").First().Html() + require.NoError(t, err) + assert.Equal(t, "<strike><i>XSS PR</i></strike>", titleHTML) + titleHTML, err = htmlDoc.doc.Find(".comment-list .timeline-item.event .text b").Next().Html() + require.NoError(t, err) + assert.Equal(t, "<u>XSS PR</u>", titleHTML) + }) +} + +func testUIDeleteBranch(t *testing.T, session *TestSession, ownerName, repoName, branchName string) { + relURL := "/" + path.Join(ownerName, repoName, "branches") + req := NewRequest(t, "GET", relURL) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + req = NewRequestWithValues(t, "POST", relURL+"/delete", map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "name": branchName, + }) + session.MakeRequest(t, req, http.StatusOK) +} + +func testDeleteRepository(t *testing.T, session *TestSession, ownerName, repoName string) { + relURL := "/" + path.Join(ownerName, repoName, "settings") + req := NewRequest(t, "GET", relURL) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + req = NewRequestWithValues(t, "POST", relURL+"?action=delete", map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "repo_name": fmt.Sprintf("%s/%s", ownerName, repoName), + }) + session.MakeRequest(t, req, http.StatusSeeOther) +} + +func TestPullBranchDelete(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testCreateBranch(t, session, "user1", "repo1", "branch/master", "master1", http.StatusSeeOther) + testEditFile(t, session, "user1", "repo1", "master1", "README.md", "Hello, World (Edited)\n") + resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master1", "This is a pull title") + + // check the redirected URL + url := test.RedirectURL(resp) + assert.Regexp(t, "^/user2/repo1/pulls/[0-9]*$", url) + req := NewRequest(t, "GET", url) + session.MakeRequest(t, req, http.StatusOK) + + // delete head branch and confirm pull page is ok + testUIDeleteBranch(t, session, "user1", "repo1", "master1") + req = NewRequest(t, "GET", url) + session.MakeRequest(t, req, http.StatusOK) + + // delete head repository and confirm pull page is ok + testDeleteRepository(t, session, "user1", "repo1") + req = NewRequest(t, "GET", url) + session.MakeRequest(t, req, http.StatusOK) + }) +} + +func TestRecentlyPushed(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + + testCreateBranch(t, session, "user1", "repo1", "branch/master", "recent-push", http.StatusSeeOther) + testEditFile(t, session, "user1", "repo1", "recent-push", "README.md", "Hello recently!\n") + + testCreateBranch(t, session, "user2", "repo1", "branch/master", "recent-push-base", http.StatusSeeOther) + testEditFile(t, session, "user2", "repo1", "recent-push-base", "README.md", "Hello, recently, from base!\n") + + baseRepo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "repo1") + require.NoError(t, err) + repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user1", "repo1") + require.NoError(t, err) + + enablePRs := func(t *testing.T, repo *repo_model.Repository) { + t.Helper() + + err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, + []repo_model.RepoUnit{{ + RepoID: repo.ID, + Type: unit_model.TypePullRequests, + }}, + nil) + require.NoError(t, err) + } + + disablePRs := func(t *testing.T, repo *repo_model.Repository) { + t.Helper() + + err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, nil, + []unit_model.Type{unit_model.TypePullRequests}) + require.NoError(t, err) + } + + testBanner := func(t *testing.T) { + t.Helper() + + req := NewRequest(t, "GET", "/user1/repo1") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + message := strings.TrimSpace(htmlDoc.Find(".ui.message").Text()) + link, _ := htmlDoc.Find(".ui.message a").Attr("href") + expectedMessage := "You pushed on branch recent-push" + + assert.Contains(t, message, expectedMessage) + assert.Equal(t, "/user1/repo1/src/branch/recent-push", link) + } + + // Test that there's a recently pushed branches banner, and it contains + // a link to the branch. + t.Run("recently-pushed-banner", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testBanner(t) + }) + + // Test that it is still there if the fork has PRs disabled, but the + // base repo still has them enabled. + t.Run("with-fork-prs-disabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer func() { + enablePRs(t, repo) + }() + + disablePRs(t, repo) + testBanner(t) + }) + + // Test that it is still there if the fork has PRs enabled, but the base + // repo does not. + t.Run("with-base-prs-disabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer func() { + enablePRs(t, baseRepo) + }() + + disablePRs(t, baseRepo) + testBanner(t) + }) + + // Test that the banner is not present if both the base and current + // repo have PRs disabled. + t.Run("with-prs-disabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer func() { + enablePRs(t, baseRepo) + enablePRs(t, repo) + }() + + disablePRs(t, repo) + disablePRs(t, baseRepo) + + req := NewRequest(t, "GET", "/user1/repo1") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, ".ui.message", false) + }) + + // Test that visiting the base repo has the banner too, and includes + // recent push notifications from both the fork, and the base repo. + t.Run("on the base repo", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Count recently pushed branches on the fork + req := NewRequest(t, "GET", "/user1/repo1") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, ".ui.message", true) + + // Count recently pushed branches on the base repo + req = NewRequest(t, "GET", "/user2/repo1") + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + messageCountOnBase := htmlDoc.Find(".ui.message").Length() + + // We have two messages on the base: one from the fork, one on the + // base itself. + assert.Equal(t, 2, messageCountOnBase) + }) + + // Test that the banner's links point to the right repos + t.Run("link validity", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // We're testing against the origin repo, because that has both + // local branches, and another from a fork, so we can test both in + // one test! + + req := NewRequest(t, "GET", "/user2/repo1") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + messages := htmlDoc.Find(".ui.message") + + prButtons := messages.Find("a[role='button']") + branchLinks := messages.Find("a[href*='/src/branch/']") + + // ** base repo tests ** + basePRLink, _ := prButtons.First().Attr("href") + baseBranchLink, _ := branchLinks.First().Attr("href") + baseBranchName := branchLinks.First().Text() + + // branch in the same repo does not have a `user/repo:` qualifier. + assert.Equal(t, "recent-push-base", baseBranchName) + // branch link points to the same repo + assert.Equal(t, "/user2/repo1/src/branch/recent-push-base", baseBranchLink) + // PR link compares against the correct rep, and unqualified branch name + assert.Equal(t, "/user2/repo1/compare/master...recent-push-base", basePRLink) + + // ** forked repo tests ** + forkPRLink, _ := prButtons.Last().Attr("href") + forkBranchLink, _ := branchLinks.Last().Attr("href") + forkBranchName := branchLinks.Last().Text() + + // branch in the forked repo has a `user/repo:` qualifier. + assert.Equal(t, "user1/repo1:recent-push", forkBranchName) + // branch link points to the forked repo + assert.Equal(t, "/user1/repo1/src/branch/recent-push", forkBranchLink) + // PR link compares against the correct rep, and qualified branch name + assert.Equal(t, "/user2/repo1/compare/master...user1/repo1:recent-push", forkPRLink) + }) + + t.Run("unrelated branches are not shown", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create a new branch with no relation to the default branch. + // 1. Create a new Tree object + cmd := git.NewCommand(db.DefaultContext, "write-tree") + treeID, _, gitErr := cmd.RunStdString(&git.RunOpts{Dir: repo.RepoPath()}) + require.NoError(t, gitErr) + treeID = strings.TrimSpace(treeID) + // 2. Create a new (empty) commit + cmd = git.NewCommand(db.DefaultContext, "commit-tree", "-m", "Initial orphan commit").AddDynamicArguments(treeID) + commitID, _, gitErr := cmd.RunStdString(&git.RunOpts{Dir: repo.RepoPath()}) + require.NoError(t, gitErr) + commitID = strings.TrimSpace(commitID) + // 3. Create a new ref pointing to the orphaned commit + cmd = git.NewCommand(db.DefaultContext, "update-ref", "refs/heads/orphan1").AddDynamicArguments(commitID) + _, _, gitErr = cmd.RunStdString(&git.RunOpts{Dir: repo.RepoPath()}) + require.NoError(t, gitErr) + // 4. Sync the git repo to the database + syncErr := repo_service.AddAllRepoBranchesToSyncQueue(graceful.GetManager().ShutdownContext()) + require.NoError(t, syncErr) + // 5. Add a fresh commit, so that FindRecentlyPushedBranches has + // something to find. + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"}) + changeResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, owner, + &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "README.md", + ContentReader: strings.NewReader("a readme file"), + }, + }, + Message: "Add README.md", + OldBranch: "orphan1", + NewBranch: "orphan1", + }) + require.NoError(t, err) + assert.NotEmpty(t, changeResp) + + // Check that we only have 1 message on the main repo, the orphaned + // one is not shown. + req := NewRequest(t, "GET", "/user1/repo1") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, ".ui.message", true) + link, _ := htmlDoc.Find(".ui.message a[href*='/src/branch/']").Attr("href") + assert.Equal(t, "/user1/repo1/src/branch/recent-push", link) + }) + }) +} + +/* +Setup: +The base repository is: user2/repo1 +Fork repository to: user1/repo1 +Push extra commit to: user2/repo1, which changes README.md +Create a PR on user1/repo1 + +Test checks: +Check if pull request can be created from base to the fork repository. +*/ +func TestPullCreatePrFromBaseToFork(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + sessionFork := loginUser(t, "user1") + testRepoFork(t, sessionFork, "user2", "repo1", "user1", "repo1") + + // Edit base repository + sessionBase := loginUser(t, "user2") + testEditFile(t, sessionBase, "user2", "repo1", "master", "README.md", "Hello, World (Edited)\n") + + // Create a PR + resp := testPullCreateDirectly(t, sessionFork, "user1", "repo1", "master", "user2", "repo1", "master", "This is a pull title") + // check the redirected URL + url := test.RedirectURL(resp) + assert.Regexp(t, "^/user1/repo1/pulls/[0-9]*$", url) + }) +} diff --git a/tests/integration/pull_diff_test.go b/tests/integration/pull_diff_test.go new file mode 100644 index 0000000..5411250 --- /dev/null +++ b/tests/integration/pull_diff_test.go @@ -0,0 +1,58 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" +) + +func TestPullDiff_CompletePRDiff(t *testing.T) { + doTestPRDiff(t, "/user2/commitsonpr/pulls/1/files", false, []string{"test1.txt", "test10.txt", "test2.txt", "test3.txt", "test4.txt", "test5.txt", "test6.txt", "test7.txt", "test8.txt", "test9.txt"}) +} + +func TestPullDiff_SingleCommitPRDiff(t *testing.T) { + doTestPRDiff(t, "/user2/commitsonpr/pulls/1/commits/c5626fc9eff57eb1bb7b796b01d4d0f2f3f792a2", true, []string{"test3.txt"}) +} + +func TestPullDiff_CommitRangePRDiff(t *testing.T) { + doTestPRDiff(t, "/user2/commitsonpr/pulls/1/files/4ca8bcaf27e28504df7bf996819665986b01c847..23576dd018294e476c06e569b6b0f170d0558705", true, []string{"test2.txt", "test3.txt", "test4.txt"}) +} + +func TestPullDiff_StartingFromBaseToCommitPRDiff(t *testing.T) { + doTestPRDiff(t, "/user2/commitsonpr/pulls/1/files/c5626fc9eff57eb1bb7b796b01d4d0f2f3f792a2", true, []string{"test1.txt", "test2.txt", "test3.txt"}) +} + +func doTestPRDiff(t *testing.T, prDiffURL string, reviewBtnDisabled bool, expectedFilenames []string) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user2/commitsonpr/pulls") + session.MakeRequest(t, req, http.StatusOK) + + // Get the given PR diff url + req = NewRequest(t, "GET", prDiffURL) + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + // Assert all files are visible. + fileContents := doc.doc.Find(".file-content") + numberOfFiles := fileContents.Length() + + assert.Equal(t, len(expectedFilenames), numberOfFiles) + + fileContents.Each(func(i int, s *goquery.Selection) { + filename, _ := s.Attr("data-old-filename") + assert.Equal(t, expectedFilenames[i], filename) + }) + + // Ensure the review button is enabled for full PR reviews + assert.Equal(t, reviewBtnDisabled, doc.doc.Find(".js-btn-review").HasClass("disabled")) +} diff --git a/tests/integration/pull_icon_test.go b/tests/integration/pull_icon_test.go new file mode 100644 index 0000000..8fde547 --- /dev/null +++ b/tests/integration/pull_icon_test.go @@ -0,0 +1,257 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + issue_service "code.gitea.io/gitea/services/issue" + pull_service "code.gitea.io/gitea/services/pull" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPullRequestIcons(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + repo, _, f := tests.CreateDeclarativeRepo(t, user, "pr-icons", []unit_model.Type{unit_model.TypeCode, unit_model.TypePullRequests}, nil, nil) + defer f() + + session := loginUser(t, user.LoginName) + + // Individual PRs + t.Run("Open", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + pull := createOpenPullRequest(db.DefaultContext, t, user, repo) + testPullRequestIcon(t, session, pull, "green", "octicon-git-pull-request") + }) + + t.Run("WIP (Open)", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + pull := createOpenWipPullRequest(db.DefaultContext, t, user, repo) + testPullRequestIcon(t, session, pull, "grey", "octicon-git-pull-request-draft") + }) + + t.Run("Closed", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + pull := createClosedPullRequest(db.DefaultContext, t, user, repo) + testPullRequestIcon(t, session, pull, "red", "octicon-git-pull-request-closed") + }) + + t.Run("WIP (Closed)", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + pull := createClosedWipPullRequest(db.DefaultContext, t, user, repo) + testPullRequestIcon(t, session, pull, "red", "octicon-git-pull-request-closed") + }) + + t.Run("Merged", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + pull := createMergedPullRequest(db.DefaultContext, t, user, repo) + testPullRequestIcon(t, session, pull, "purple", "octicon-git-merge") + }) + + // List + req := NewRequest(t, "GET", repo.HTMLURL()+"/pulls?state=all") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + t.Run("List Open", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testPullRequestListIcon(t, doc, "open", "green", "octicon-git-pull-request") + }) + + t.Run("List WIP (Open)", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testPullRequestListIcon(t, doc, "open-wip", "grey", "octicon-git-pull-request-draft") + }) + + t.Run("List Closed", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testPullRequestListIcon(t, doc, "closed", "red", "octicon-git-pull-request-closed") + }) + + t.Run("List Closed (WIP)", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testPullRequestListIcon(t, doc, "closed-wip", "red", "octicon-git-pull-request-closed") + }) + + t.Run("List Merged", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testPullRequestListIcon(t, doc, "merged", "purple", "octicon-git-merge") + }) + }) +} + +func testPullRequestIcon(t *testing.T, session *TestSession, pr *issues_model.PullRequest, expectedColor, expectedIcon string) { + req := NewRequest(t, "GET", pr.Issue.HTMLURL()) + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + doc.AssertElement(t, fmt.Sprintf("div.issue-state-label.%s > svg.%s", expectedColor, expectedIcon), true) + + req = NewRequest(t, "GET", pr.BaseRepo.HTMLURL()+"/branches") + resp = session.MakeRequest(t, req, http.StatusOK) + doc = NewHTMLParser(t, resp.Body) + doc.AssertElement(t, fmt.Sprintf(`a[href="/%s/pulls/%d"].%s > svg.%s`, pr.BaseRepo.FullName(), pr.Issue.Index, expectedColor, expectedIcon), true) +} + +func testPullRequestListIcon(t *testing.T, doc *HTMLDoc, name, expectedColor, expectedIcon string) { + sel := doc.doc.Find("div#issue-list > div.flex-item"). + FilterFunction(func(_ int, selection *goquery.Selection) bool { + return selection.Find(fmt.Sprintf(`div.flex-item-icon > svg.%s.%s`, expectedColor, expectedIcon)).Length() == 1 && + strings.HasSuffix(selection.Find("a.issue-title").Text(), name) + }) + + assert.Equal(t, 1, sel.Length()) +} + +func createOpenPullRequest(ctx context.Context, t *testing.T, user *user_model.User, repo *repo_model.Repository) *issues_model.PullRequest { + pull := createPullRequest(t, user, repo, "open") + + assert.False(t, pull.Issue.IsClosed) + assert.False(t, pull.HasMerged) + assert.False(t, pull.IsWorkInProgress(ctx)) + + return pull +} + +func createOpenWipPullRequest(ctx context.Context, t *testing.T, user *user_model.User, repo *repo_model.Repository) *issues_model.PullRequest { + pull := createPullRequest(t, user, repo, "open-wip") + + err := issue_service.ChangeTitle(ctx, pull.Issue, user, "WIP: "+pull.Issue.Title) + require.NoError(t, err) + + assert.False(t, pull.Issue.IsClosed) + assert.False(t, pull.HasMerged) + assert.True(t, pull.IsWorkInProgress(ctx)) + + return pull +} + +func createClosedPullRequest(ctx context.Context, t *testing.T, user *user_model.User, repo *repo_model.Repository) *issues_model.PullRequest { + pull := createPullRequest(t, user, repo, "closed") + + err := issue_service.ChangeStatus(ctx, pull.Issue, user, "", true) + require.NoError(t, err) + + assert.True(t, pull.Issue.IsClosed) + assert.False(t, pull.HasMerged) + assert.False(t, pull.IsWorkInProgress(ctx)) + + return pull +} + +func createClosedWipPullRequest(ctx context.Context, t *testing.T, user *user_model.User, repo *repo_model.Repository) *issues_model.PullRequest { + pull := createPullRequest(t, user, repo, "closed-wip") + + err := issue_service.ChangeTitle(ctx, pull.Issue, user, "WIP: "+pull.Issue.Title) + require.NoError(t, err) + + err = issue_service.ChangeStatus(ctx, pull.Issue, user, "", true) + require.NoError(t, err) + + assert.True(t, pull.Issue.IsClosed) + assert.False(t, pull.HasMerged) + assert.True(t, pull.IsWorkInProgress(ctx)) + + return pull +} + +func createMergedPullRequest(ctx context.Context, t *testing.T, user *user_model.User, repo *repo_model.Repository) *issues_model.PullRequest { + pull := createPullRequest(t, user, repo, "merged") + + gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) + defer gitRepo.Close() + + require.NoError(t, err) + + err = pull_service.Merge(ctx, pull, user, gitRepo, repo_model.MergeStyleMerge, pull.HeadCommitID, "merge", false) + require.NoError(t, err) + + assert.False(t, pull.Issue.IsClosed) + assert.True(t, pull.CanAutoMerge()) + assert.False(t, pull.IsWorkInProgress(ctx)) + + return pull +} + +func createPullRequest(t *testing.T, user *user_model.User, repo *repo_model.Repository, name string) *issues_model.PullRequest { + branch := "branch-" + name + title := "Testing " + name + + _, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "update", + TreePath: "README.md", + ContentReader: strings.NewReader("Update README"), + }, + }, + Message: "Update README", + OldBranch: "main", + NewBranch: branch, + Author: &files_service.IdentityOptions{ + Name: user.Name, + Email: user.Email, + }, + Committer: &files_service.IdentityOptions{ + Name: user.Name, + Email: user.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + + require.NoError(t, err) + + pullIssue := &issues_model.Issue{ + RepoID: repo.ID, + Title: title, + PosterID: user.ID, + Poster: user, + IsPull: true, + } + + pullRequest := &issues_model.PullRequest{ + HeadRepoID: repo.ID, + BaseRepoID: repo.ID, + HeadBranch: branch, + BaseBranch: "main", + HeadRepo: repo, + BaseRepo: repo, + Type: issues_model.PullRequestGitea, + } + err = pull_service.NewPullRequest(git.DefaultContext, repo, pullIssue, nil, nil, pullRequest, nil) + require.NoError(t, err) + + return pullRequest +} diff --git a/tests/integration/pull_merge_test.go b/tests/integration/pull_merge_test.go new file mode 100644 index 0000000..caf1001 --- /dev/null +++ b/tests/integration/pull_merge_test.go @@ -0,0 +1,1184 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "code.gitea.io/gitea/models" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + pull_model "code.gitea.io/gitea/models/pull" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/hostmatcher" + "code.gitea.io/gitea/modules/queue" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/services/automerge" + forgejo_context "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/pull" + commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus" + files_service "code.gitea.io/gitea/services/repository/files" + webhook_service "code.gitea.io/gitea/services/webhook" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type optionsPullMerge map[string]string + +func testPullMerge(t *testing.T, session *TestSession, user, repo, pullnum string, mergeStyle repo_model.MergeStyle, deleteBranch bool) *httptest.ResponseRecorder { + options := optionsPullMerge{ + "do": string(mergeStyle), + } + if deleteBranch { + options["delete_branch_after_merge"] = "on" + } + + return testPullMergeForm(t, session, http.StatusOK, user, repo, pullnum, options) +} + +func testPullMergeForm(t *testing.T, session *TestSession, expectedCode int, user, repo, pullnum string, addOptions optionsPullMerge) *httptest.ResponseRecorder { + req := NewRequest(t, "GET", path.Join(user, repo, "pulls", pullnum)) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + link := path.Join(user, repo, "pulls", pullnum, "merge") + + options := map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + } + for k, v := range addOptions { + options[k] = v + } + + req = NewRequestWithValues(t, "POST", link, options) + resp = session.MakeRequest(t, req, expectedCode) + + if expectedCode == http.StatusOK { + respJSON := struct { + Redirect string + }{} + DecodeJSON(t, resp, &respJSON) + + assert.EqualValues(t, fmt.Sprintf("/%s/%s/pulls/%s", user, repo, pullnum), respJSON.Redirect) + } + + return resp +} + +func testPullCleanUp(t *testing.T, session *TestSession, user, repo, pullnum string) *httptest.ResponseRecorder { + req := NewRequest(t, "GET", path.Join(user, repo, "pulls", pullnum)) + resp := session.MakeRequest(t, req, http.StatusOK) + + // Click the little button to create a pull + htmlDoc := NewHTMLParser(t, resp.Body) + link, exists := htmlDoc.doc.Find(".timeline-item .delete-button").Attr("data-url") + assert.True(t, exists, "The template has changed, can not find delete button url") + req = NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + }) + resp = session.MakeRequest(t, req, http.StatusOK) + + return resp +} + +// returns the hook tasks, order by ID desc. +func retrieveHookTasks(t *testing.T, hookID int64, activateWebhook bool) []*webhook.HookTask { + t.Helper() + if activateWebhook { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(s.Close) + updated, err := db.GetEngine(db.DefaultContext).ID(hookID).Cols("is_active", "url").Update(webhook.Webhook{ + IsActive: true, + URL: s.URL, + }) + + // allow webhook deliveries on localhost + t.Cleanup(test.MockVariableValue(&setting.Webhook.AllowedHostList, hostmatcher.MatchBuiltinLoopback)) + webhook_service.Init() + + assert.Equal(t, int64(1), updated) + require.NoError(t, err) + } + + hookTasks, err := webhook.HookTasks(db.DefaultContext, hookID, 1) + require.NoError(t, err) + return hookTasks +} + +func TestPullMerge(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + hookTasks := retrieveHookTasks(t, 1, true) + hookTasksLenBefore := len(hookTasks) + + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n") + + resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "This is a pull title") + + elem := strings.Split(test.RedirectURL(resp), "/") + assert.EqualValues(t, "pulls", elem[3]) + testPullMerge(t, session, elem[1], elem[2], elem[4], repo_model.MergeStyleMerge, false) + + hookTasks = retrieveHookTasks(t, 1, false) + assert.Len(t, hookTasks, hookTasksLenBefore+1) + }) +} + +func TestPullRebase(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + hookTasks := retrieveHookTasks(t, 1, true) + hookTasksLenBefore := len(hookTasks) + + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n") + + resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "This is a pull title") + + elem := strings.Split(test.RedirectURL(resp), "/") + assert.EqualValues(t, "pulls", elem[3]) + testPullMerge(t, session, elem[1], elem[2], elem[4], repo_model.MergeStyleRebase, false) + + hookTasks = retrieveHookTasks(t, 1, false) + assert.Len(t, hookTasks, hookTasksLenBefore+1) + }) +} + +func TestPullRebaseMerge(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + hookTasks := retrieveHookTasks(t, 1, true) + hookTasksLenBefore := len(hookTasks) + + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n") + + resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "This is a pull title") + + elem := strings.Split(test.RedirectURL(resp), "/") + assert.EqualValues(t, "pulls", elem[3]) + testPullMerge(t, session, elem[1], elem[2], elem[4], repo_model.MergeStyleRebaseMerge, false) + + hookTasks = retrieveHookTasks(t, 1, false) + assert.Len(t, hookTasks, hookTasksLenBefore+1) + }) +} + +func TestPullSquash(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + hookTasks := retrieveHookTasks(t, 1, true) + hookTasksLenBefore := len(hookTasks) + + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n") + testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited!)\n") + + resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "This is a pull title") + + elem := strings.Split(test.RedirectURL(resp), "/") + assert.EqualValues(t, "pulls", elem[3]) + testPullMerge(t, session, elem[1], elem[2], elem[4], repo_model.MergeStyleSquash, false) + + hookTasks = retrieveHookTasks(t, 1, false) + assert.Len(t, hookTasks, hookTasksLenBefore+1) + }) +} + +func TestPullCleanUpAfterMerge(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFileToNewBranch(t, session, "user1", "repo1", "master", "feature/test", "README.md", "Hello, World (Edited - TestPullCleanUpAfterMerge)\n") + + resp := testPullCreate(t, session, "user1", "repo1", false, "master", "feature/test", "This is a pull title") + + elem := strings.Split(test.RedirectURL(resp), "/") + assert.EqualValues(t, "pulls", elem[3]) + testPullMerge(t, session, elem[1], elem[2], elem[4], repo_model.MergeStyleMerge, false) + + // Check PR branch deletion + resp = testPullCleanUp(t, session, elem[1], elem[2], elem[4]) + respJSON := struct { + Redirect string + }{} + DecodeJSON(t, resp, &respJSON) + + assert.NotEmpty(t, respJSON.Redirect, "Redirected URL is not found") + + elem = strings.Split(respJSON.Redirect, "/") + assert.EqualValues(t, "pulls", elem[3]) + + // Check branch deletion result + req := NewRequest(t, "GET", respJSON.Redirect) + resp = session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + resultMsg := htmlDoc.doc.Find(".ui.message>p").Text() + + assert.EqualValues(t, "Branch \"user1/repo1:feature/test\" has been deleted.", resultMsg) + }) +} + +func TestCantMergeWorkInProgress(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n") + + resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "[wip] This is a pull title") + + req := NewRequest(t, "GET", test.RedirectURL(resp)) + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + text := strings.TrimSpace(htmlDoc.doc.Find(".merge-section > .item").Last().Text()) + assert.NotEmpty(t, text, "Can't find WIP text") + + assert.Contains(t, text, translation.NewLocale("en-US").TrString("repo.pulls.cannot_merge_work_in_progress"), "Unable to find WIP text") + assert.Contains(t, text, "[wip]", "Unable to find WIP text") + }) +} + +func TestCantMergeConflict(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFileToNewBranch(t, session, "user1", "repo1", "master", "conflict", "README.md", "Hello, World (Edited Once)\n") + testEditFileToNewBranch(t, session, "user1", "repo1", "master", "base", "README.md", "Hello, World (Edited Twice)\n") + + // Use API to create a conflicting pr + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", "user1", "repo1"), &api.CreatePullRequestOption{ + Head: "conflict", + Base: "base", + Title: "create a conflicting pr", + }).AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusCreated) + + // Now this PR will be marked conflict - or at least a race will do - so drop down to pure code at this point... + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + Name: "user1", + }) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ + OwnerID: user1.ID, + Name: "repo1", + }) + + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ + HeadRepoID: repo1.ID, + BaseRepoID: repo1.ID, + HeadBranch: "conflict", + BaseBranch: "base", + }) + + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo1) + require.NoError(t, err) + + err = pull.Merge(context.Background(), pr, user1, gitRepo, repo_model.MergeStyleMerge, "", "CONFLICT", false) + require.Error(t, err, "Merge should return an error due to conflict") + assert.True(t, models.IsErrMergeConflicts(err), "Merge error is not a conflict error") + + err = pull.Merge(context.Background(), pr, user1, gitRepo, repo_model.MergeStyleRebase, "", "CONFLICT", false) + require.Error(t, err, "Merge should return an error due to conflict") + assert.True(t, models.IsErrRebaseConflicts(err), "Merge error is not a conflict error") + gitRepo.Close() + }) +} + +func TestCantMergeUnrelated(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFileToNewBranch(t, session, "user1", "repo1", "master", "base", "README.md", "Hello, World (Edited Twice)\n") + + // Now we want to create a commit on a branch that is totally unrelated to our current head + // Drop down to pure code at this point + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + Name: "user1", + }) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ + OwnerID: user1.ID, + Name: "repo1", + }) + path := repo_model.RepoPath(user1.Name, repo1.Name) + + err := git.NewCommand(git.DefaultContext, "read-tree", "--empty").Run(&git.RunOpts{Dir: path}) + require.NoError(t, err) + + stdin := bytes.NewBufferString("Unrelated File") + var stdout strings.Builder + err = git.NewCommand(git.DefaultContext, "hash-object", "-w", "--stdin").Run(&git.RunOpts{ + Dir: path, + Stdin: stdin, + Stdout: &stdout, + }) + + require.NoError(t, err) + sha := strings.TrimSpace(stdout.String()) + + _, _, err = git.NewCommand(git.DefaultContext, "update-index", "--add", "--replace", "--cacheinfo").AddDynamicArguments("100644", sha, "somewher-over-the-rainbow").RunStdString(&git.RunOpts{Dir: path}) + require.NoError(t, err) + + treeSha, _, err := git.NewCommand(git.DefaultContext, "write-tree").RunStdString(&git.RunOpts{Dir: path}) + require.NoError(t, err) + treeSha = strings.TrimSpace(treeSha) + + commitTimeStr := time.Now().Format(time.RFC3339) + doerSig := user1.NewGitSig() + env := append(os.Environ(), + "GIT_AUTHOR_NAME="+doerSig.Name, + "GIT_AUTHOR_EMAIL="+doerSig.Email, + "GIT_AUTHOR_DATE="+commitTimeStr, + "GIT_COMMITTER_NAME="+doerSig.Name, + "GIT_COMMITTER_EMAIL="+doerSig.Email, + "GIT_COMMITTER_DATE="+commitTimeStr, + ) + + messageBytes := new(bytes.Buffer) + _, _ = messageBytes.WriteString("Unrelated") + _, _ = messageBytes.WriteString("\n") + + stdout.Reset() + err = git.NewCommand(git.DefaultContext, "commit-tree").AddDynamicArguments(treeSha). + Run(&git.RunOpts{ + Env: env, + Dir: path, + Stdin: messageBytes, + Stdout: &stdout, + }) + require.NoError(t, err) + commitSha := strings.TrimSpace(stdout.String()) + + _, _, err = git.NewCommand(git.DefaultContext, "branch", "unrelated").AddDynamicArguments(commitSha).RunStdString(&git.RunOpts{Dir: path}) + require.NoError(t, err) + + testEditFileToNewBranch(t, session, "user1", "repo1", "master", "conflict", "README.md", "Hello, World (Edited Once)\n") + + // Use API to create a conflicting pr + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", "user1", "repo1"), &api.CreatePullRequestOption{ + Head: "unrelated", + Base: "base", + Title: "create an unrelated pr", + }).AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusCreated) + + // Now this PR could be marked conflict - or at least a race may occur - so drop down to pure code at this point... + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo1) + require.NoError(t, err) + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ + HeadRepoID: repo1.ID, + BaseRepoID: repo1.ID, + HeadBranch: "unrelated", + BaseBranch: "base", + }) + + err = pull.Merge(context.Background(), pr, user1, gitRepo, repo_model.MergeStyleMerge, "", "UNRELATED", false) + require.Error(t, err, "Merge should return an error due to unrelated") + assert.True(t, models.IsErrMergeUnrelatedHistories(err), "Merge error is not a unrelated histories error") + gitRepo.Close() + }) +} + +func TestFastForwardOnlyMerge(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFileToNewBranch(t, session, "user1", "repo1", "master", "update", "README.md", "Hello, World 2\n") + + // Use API to create a pr from update to master + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", "user1", "repo1"), &api.CreatePullRequestOption{ + Head: "update", + Base: "master", + Title: "create a pr that can be fast-forward-only merged", + }).AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusCreated) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + Name: "user1", + }) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ + OwnerID: user1.ID, + Name: "repo1", + }) + + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ + HeadRepoID: repo1.ID, + BaseRepoID: repo1.ID, + HeadBranch: "update", + BaseBranch: "master", + }) + + gitRepo, err := git.OpenRepository(git.DefaultContext, repo_model.RepoPath(user1.Name, repo1.Name)) + require.NoError(t, err) + + err = pull.Merge(context.Background(), pr, user1, gitRepo, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false) + + require.NoError(t, err) + + gitRepo.Close() + }) +} + +func TestCantFastForwardOnlyMergeDiverging(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFileToNewBranch(t, session, "user1", "repo1", "master", "diverging", "README.md", "Hello, World diverged\n") + testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World 2\n") + + // Use API to create a pr from diverging to update + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", "user1", "repo1"), &api.CreatePullRequestOption{ + Head: "diverging", + Base: "master", + Title: "create a pr from a diverging branch", + }).AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusCreated) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ + Name: "user1", + }) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ + OwnerID: user1.ID, + Name: "repo1", + }) + + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ + HeadRepoID: repo1.ID, + BaseRepoID: repo1.ID, + HeadBranch: "diverging", + BaseBranch: "master", + }) + + gitRepo, err := git.OpenRepository(git.DefaultContext, repo_model.RepoPath(user1.Name, repo1.Name)) + require.NoError(t, err) + + err = pull.Merge(context.Background(), pr, user1, gitRepo, repo_model.MergeStyleFastForwardOnly, "", "DIVERGING", false) + + require.Error(t, err, "Merge should return an error due to being for a diverging branch") + assert.True(t, models.IsErrMergeDivergingFastForwardOnly(err), "Merge error is not a diverging fast-forward-only error") + + gitRepo.Close() + }) +} + +func TestConflictChecking(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // Create new clean repo to test conflict checking. + baseRepo, _, f := tests.CreateDeclarativeRepo(t, user, "conflict-checking", nil, nil, nil) + defer f() + + // create a commit on new branch. + _, err := files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, user, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "important_file", + ContentReader: strings.NewReader("Just a non-important file"), + }, + }, + Message: "Add a important file", + OldBranch: "main", + NewBranch: "important-secrets", + }) + require.NoError(t, err) + + // create a commit on main branch. + _, err = files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, user, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "important_file", + ContentReader: strings.NewReader("Not the same content :P"), + }, + }, + Message: "Add a important file", + OldBranch: "main", + NewBranch: "main", + }) + require.NoError(t, err) + + // create Pull to merge the important-secrets branch into main branch. + pullIssue := &issues_model.Issue{ + RepoID: baseRepo.ID, + Title: "PR with conflict!", + PosterID: user.ID, + Poster: user, + IsPull: true, + } + + pullRequest := &issues_model.PullRequest{ + HeadRepoID: baseRepo.ID, + BaseRepoID: baseRepo.ID, + HeadBranch: "important-secrets", + BaseBranch: "main", + HeadRepo: baseRepo, + BaseRepo: baseRepo, + Type: issues_model.PullRequestGitea, + } + err = pull.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil) + require.NoError(t, err) + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "PR with conflict!"}) + require.NoError(t, issue.LoadPullRequest(db.DefaultContext)) + conflictingPR := issue.PullRequest + + // Ensure conflictedFiles is populated. + assert.Len(t, conflictingPR.ConflictedFiles, 1) + // Check if status is correct. + assert.Equal(t, issues_model.PullRequestStatusConflict, conflictingPR.Status) + // Ensure that mergeable returns false + assert.False(t, conflictingPR.Mergeable(db.DefaultContext)) + }) +} + +func TestPullRetargetChildOnBranchDelete(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + session := loginUser(t, "user1") + testEditFileToNewBranch(t, session, "user2", "repo1", "master", "base-pr", "README.md", "Hello, World\n(Edited - TestPullRetargetOnCleanup - base PR)\n") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFileToNewBranch(t, session, "user1", "repo1", "base-pr", "child-pr", "README.md", "Hello, World\n(Edited - TestPullRetargetOnCleanup - base PR)\n(Edited - TestPullRetargetOnCleanup - child PR)") + + respBasePR := testPullCreate(t, session, "user2", "repo1", true, "master", "base-pr", "Base Pull Request") + elemBasePR := strings.Split(test.RedirectURL(respBasePR), "/") + assert.EqualValues(t, "pulls", elemBasePR[3]) + + respChildPR := testPullCreate(t, session, "user1", "repo1", false, "base-pr", "child-pr", "Child Pull Request") + elemChildPR := strings.Split(test.RedirectURL(respChildPR), "/") + assert.EqualValues(t, "pulls", elemChildPR[3]) + + testPullMerge(t, session, elemBasePR[1], elemBasePR[2], elemBasePR[4], repo_model.MergeStyleMerge, true) + + // Check child PR + req := NewRequest(t, "GET", test.RedirectURL(respChildPR)) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + targetBranch := htmlDoc.doc.Find("#branch_target>a").Text() + prStatus := strings.TrimSpace(htmlDoc.doc.Find(".issue-title-meta>.issue-state-label").Text()) + + assert.EqualValues(t, "master", targetBranch) + assert.EqualValues(t, "Open", prStatus) + }) +} + +func TestPullDontRetargetChildOnWrongRepo(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFileToNewBranch(t, session, "user1", "repo1", "master", "base-pr", "README.md", "Hello, World\n(Edited - TestPullDontRetargetChildOnWrongRepo - base PR)\n") + testEditFileToNewBranch(t, session, "user1", "repo1", "base-pr", "child-pr", "README.md", "Hello, World\n(Edited - TestPullDontRetargetChildOnWrongRepo - base PR)\n(Edited - TestPullDontRetargetChildOnWrongRepo - child PR)") + + respBasePR := testPullCreate(t, session, "user1", "repo1", false, "master", "base-pr", "Base Pull Request") + elemBasePR := strings.Split(test.RedirectURL(respBasePR), "/") + assert.EqualValues(t, "pulls", elemBasePR[3]) + + respChildPR := testPullCreate(t, session, "user1", "repo1", true, "base-pr", "child-pr", "Child Pull Request") + elemChildPR := strings.Split(test.RedirectURL(respChildPR), "/") + assert.EqualValues(t, "pulls", elemChildPR[3]) + + testPullMerge(t, session, elemBasePR[1], elemBasePR[2], elemBasePR[4], repo_model.MergeStyleMerge, true) + + // Check child PR + req := NewRequest(t, "GET", test.RedirectURL(respChildPR)) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + targetBranch := htmlDoc.doc.Find("#branch_target>a").Text() + prStatus := strings.TrimSpace(htmlDoc.doc.Find(".issue-title-meta>.issue-state-label").Text()) + + assert.EqualValues(t, "base-pr", targetBranch) + assert.EqualValues(t, "Closed", prStatus) + }) +} + +func TestPullMergeIndexerNotifier(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + // create a pull request + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n") + createPullResp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "Indexer notifier test pull") + + require.NoError(t, queue.GetManager().FlushAll(context.Background(), 0)) + time.Sleep(time.Second) + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ + OwnerName: "user2", + Name: "repo1", + }) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ + RepoID: repo1.ID, + Title: "Indexer notifier test pull", + IsPull: true, + IsClosed: false, + }) + + // build the request for searching issues + link, _ := url.Parse("/api/v1/repos/issues/search") + query := url.Values{} + query.Add("state", "closed") + query.Add("type", "pulls") + query.Add("q", "notifier") + link.RawQuery = query.Encode() + + // search issues + searchIssuesResp := session.MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK) + var apiIssuesBefore []*api.Issue + DecodeJSON(t, searchIssuesResp, &apiIssuesBefore) + assert.Empty(t, apiIssuesBefore) + + // merge the pull request + elem := strings.Split(test.RedirectURL(createPullResp), "/") + assert.EqualValues(t, "pulls", elem[3]) + testPullMerge(t, session, elem[1], elem[2], elem[4], repo_model.MergeStyleMerge, false) + + // check if the issue is closed + issue = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ + ID: issue.ID, + }) + assert.True(t, issue.IsClosed) + + require.NoError(t, queue.GetManager().FlushAll(context.Background(), 0)) + time.Sleep(time.Second) + + // search issues again + searchIssuesResp = session.MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK) + var apiIssuesAfter []*api.Issue + DecodeJSON(t, searchIssuesResp, &apiIssuesAfter) + if assert.Len(t, apiIssuesAfter, 1) { + assert.Equal(t, issue.ID, apiIssuesAfter[0].ID) + } + }) +} + +func testResetRepo(t *testing.T, repoPath, branch, commitID string) { + f, err := os.OpenFile(filepath.Join(repoPath, "refs", "heads", branch), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + require.NoError(t, err) + _, err = f.WriteString(commitID + "\n") + require.NoError(t, err) + f.Close() + + repo, err := git.OpenRepository(context.Background(), repoPath) + require.NoError(t, err) + defer repo.Close() + id, err := repo.GetBranchCommitID(branch) + require.NoError(t, err) + assert.EqualValues(t, commitID, id) +} + +func TestPullMergeBranchProtect(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + admin := "user1" + owner := "user5" + notOwner := "user4" + repo := "repo4" + + dstPath := t.TempDir() + + u.Path = fmt.Sprintf("%s/%s.git", owner, repo) + u.User = url.UserPassword(owner, userPassword) + + t.Run("Clone", doGitClone(dstPath, u)) + + for _, testCase := range []struct { + name string + doer string + expectedCode map[string]int + filename string + protectBranch parameterProtectBranch + }{ + { + name: "SuccessAdminNotEnoughMergeRequiredApprovals", + doer: admin, + expectedCode: map[string]int{"api": http.StatusOK, "web": http.StatusOK}, + filename: "branch-data-file-", + protectBranch: parameterProtectBranch{ + "required_approvals": "1", + "apply_to_admins": "true", + }, + }, + { + name: "FailOwnerProtectedFile", + doer: owner, + expectedCode: map[string]int{"api": http.StatusMethodNotAllowed, "web": http.StatusBadRequest}, + filename: "protected-file-", + protectBranch: parameterProtectBranch{ + "protected_file_patterns": "protected-file-*", + "apply_to_admins": "true", + }, + }, + { + name: "OwnerProtectedFile", + doer: owner, + expectedCode: map[string]int{"api": http.StatusOK, "web": http.StatusOK}, + filename: "protected-file-", + protectBranch: parameterProtectBranch{ + "protected_file_patterns": "protected-file-*", + "apply_to_admins": "false", + }, + }, + { + name: "FailNotOwnerProtectedFile", + doer: notOwner, + expectedCode: map[string]int{"api": http.StatusMethodNotAllowed, "web": http.StatusBadRequest}, + filename: "protected-file-", + protectBranch: parameterProtectBranch{ + "protected_file_patterns": "protected-file-*", + }, + }, + { + name: "FailOwnerNotEnoughMergeRequiredApprovals", + doer: owner, + expectedCode: map[string]int{"api": http.StatusMethodNotAllowed, "web": http.StatusBadRequest}, + filename: "branch-data-file-", + protectBranch: parameterProtectBranch{ + "required_approvals": "1", + "apply_to_admins": "true", + }, + }, + { + name: "SuccessOwnerNotEnoughMergeRequiredApprovals", + doer: owner, + expectedCode: map[string]int{"api": http.StatusOK, "web": http.StatusOK}, + filename: "branch-data-file-", + protectBranch: parameterProtectBranch{ + "required_approvals": "1", + "apply_to_admins": "false", + }, + }, + { + name: "FailNotOwnerNotEnoughMergeRequiredApprovals", + doer: notOwner, + expectedCode: map[string]int{"api": http.StatusMethodNotAllowed, "web": http.StatusBadRequest}, + filename: "branch-data-file-", + protectBranch: parameterProtectBranch{ + "required_approvals": "1", + "apply_to_admins": "false", + }, + }, + { + name: "SuccessNotOwner", + doer: notOwner, + expectedCode: map[string]int{"api": http.StatusOK, "web": http.StatusOK}, + filename: "branch-data-file-", + protectBranch: parameterProtectBranch{ + "required_approvals": "0", + }, + }, + } { + mergeWith := func(t *testing.T, ctx APITestContext, apiOrWeb string, expectedCode int, pr int64) { + switch apiOrWeb { + case "api": + ctx.ExpectedCode = expectedCode + doAPIMergePullRequestForm(t, ctx, owner, repo, pr, + &forms.MergePullRequestForm{ + MergeMessageField: "doAPIMergePullRequest Merge", + Do: string(repo_model.MergeStyleMerge), + ForceMerge: true, + }) + ctx.ExpectedCode = 0 + case "web": + testPullMergeForm(t, ctx.Session, expectedCode, owner, repo, fmt.Sprintf("%d", pr), optionsPullMerge{ + "do": string(repo_model.MergeStyleMerge), + "force_merge": "true", + }) + default: + panic(apiOrWeb) + } + } + for _, withAPIOrWeb := range []string{"api", "web"} { + t.Run(testCase.name+" "+withAPIOrWeb, func(t *testing.T) { + branch := testCase.name + "-" + withAPIOrWeb + unprotected := branch + "-unprotected" + doGitCheckoutBranch(dstPath, "master")(t) + doGitCreateBranch(dstPath, branch)(t) + doGitPushTestRepository(dstPath, "origin", branch)(t) + + ctx := NewAPITestContext(t, owner, repo, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + doProtectBranch(ctx, branch, testCase.protectBranch)(t) + + ctx = NewAPITestContext(t, testCase.doer, "not used", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + ctx.Username = owner + ctx.Reponame = repo + _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", testCase.filename) + require.NoError(t, err) + doGitPushTestRepository(dstPath, "origin", branch+":"+unprotected)(t) + pr, err := doAPICreatePullRequest(ctx, owner, repo, branch, unprotected)(t) + require.NoError(t, err) + mergeWith(t, ctx, withAPIOrWeb, testCase.expectedCode[withAPIOrWeb], pr.Index) + }) + } + } + }) +} + +func TestPullAutoMergeAfterCommitStatusSucceed(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + // create a pull request + session := loginUser(t, "user1") + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + forkedName := "repo1-1" + testRepoFork(t, session, "user2", "repo1", "user1", forkedName) + defer func() { + testDeleteRepository(t, session, "user1", forkedName) + }() + testEditFile(t, session, "user1", forkedName, "master", "README.md", "Hello, World (Edited)\n") + testPullCreate(t, session, "user1", forkedName, false, "master", "master", "Indexer notifier test pull") + + baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"}) + forkedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user1", Name: forkedName}) + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ + BaseRepoID: baseRepo.ID, + BaseBranch: "master", + HeadRepoID: forkedRepo.ID, + HeadBranch: "master", + }) + + // add protected branch for commit status + csrf := GetCSRF(t, session, "/user2/repo1/settings/branches") + // Change master branch to protected + req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{ + "_csrf": csrf, + "rule_name": "master", + "enable_push": "true", + "enable_status_check": "true", + "status_check_contexts": "gitea/actions", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + // first time insert automerge record, return true + scheduled, err := automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test") + require.NoError(t, err) + assert.True(t, scheduled) + + // second time insert automerge record, return false because it does exist + scheduled, err = automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test") + require.Error(t, err) + assert.False(t, scheduled) + + // reload pr again + pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID}) + assert.False(t, pr.HasMerged) + assert.Empty(t, pr.MergedCommitID) + + // update commit status to success, then it should be merged automatically + baseGitRepo, err := gitrepo.OpenRepository(db.DefaultContext, baseRepo) + require.NoError(t, err) + sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName()) + require.NoError(t, err) + masterCommitID, err := baseGitRepo.GetBranchCommitID("master") + require.NoError(t, err) + + branches, _, err := baseGitRepo.GetBranchNames(0, 100) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"sub-home-md-img-check", "home-md-img-check", "pr-to-update", "branch2", "DefaultBranch", "develop", "feature/1", "master"}, branches) + baseGitRepo.Close() + defer func() { + testResetRepo(t, baseRepo.RepoPath(), "master", masterCommitID) + }() + + err = commitstatus_service.CreateCommitStatus(db.DefaultContext, baseRepo, user1, sha, &git_model.CommitStatus{ + State: api.CommitStatusSuccess, + TargetURL: "https://gitea.com", + Context: "gitea/actions", + }) + require.NoError(t, err) + + time.Sleep(2 * time.Second) + + // realod pr again + pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID}) + assert.True(t, pr.HasMerged) + assert.NotEmpty(t, pr.MergedCommitID) + + unittest.AssertNotExistsBean(t, &pull_model.AutoMerge{PullID: pr.ID}) + }) +} + +func TestPullAutoMergeAfterCommitStatusSucceedAndApproval(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + // create a pull request + session := loginUser(t, "user1") + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + forkedName := "repo1-2" + testRepoFork(t, session, "user2", "repo1", "user1", forkedName) + defer func() { + testDeleteRepository(t, session, "user1", forkedName) + }() + testEditFile(t, session, "user1", forkedName, "master", "README.md", "Hello, World (Edited)\n") + testPullCreate(t, session, "user1", forkedName, false, "master", "master", "Indexer notifier test pull") + + baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"}) + forkedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user1", Name: forkedName}) + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ + BaseRepoID: baseRepo.ID, + BaseBranch: "master", + HeadRepoID: forkedRepo.ID, + HeadBranch: "master", + }) + + // add protected branch for commit status + csrf := GetCSRF(t, session, "/user2/repo1/settings/branches") + // Change master branch to protected + req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{ + "_csrf": csrf, + "rule_name": "master", + "enable_push": "true", + "enable_status_check": "true", + "status_check_contexts": "gitea/actions", + "required_approvals": "1", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + // first time insert automerge record, return true + scheduled, err := automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test") + require.NoError(t, err) + assert.True(t, scheduled) + + // second time insert automerge record, return false because it does exist + scheduled, err = automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test") + require.Error(t, err) + assert.False(t, scheduled) + + // reload pr again + pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID}) + assert.False(t, pr.HasMerged) + assert.Empty(t, pr.MergedCommitID) + + // update commit status to success, then it should be merged automatically + baseGitRepo, err := gitrepo.OpenRepository(db.DefaultContext, baseRepo) + require.NoError(t, err) + sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName()) + require.NoError(t, err) + masterCommitID, err := baseGitRepo.GetBranchCommitID("master") + require.NoError(t, err) + baseGitRepo.Close() + defer func() { + testResetRepo(t, baseRepo.RepoPath(), "master", masterCommitID) + }() + + err = commitstatus_service.CreateCommitStatus(db.DefaultContext, baseRepo, user1, sha, &git_model.CommitStatus{ + State: api.CommitStatusSuccess, + TargetURL: "https://gitea.com", + Context: "gitea/actions", + }) + require.NoError(t, err) + + time.Sleep(2 * time.Second) + + // reload pr again + pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID}) + assert.False(t, pr.HasMerged) + assert.Empty(t, pr.MergedCommitID) + + // approve the PR from non-author + approveSession := loginUser(t, "user2") + req = NewRequest(t, "GET", fmt.Sprintf("/user2/repo1/pulls/%d", pr.Index)) + resp := approveSession.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + testSubmitReview(t, approveSession, htmlDoc.GetCSRF(), "user2", "repo1", strconv.Itoa(int(pr.Index)), sha, "approve", http.StatusOK) + + time.Sleep(2 * time.Second) + + // realod pr again + pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID}) + assert.True(t, pr.HasMerged) + assert.NotEmpty(t, pr.MergedCommitID) + + unittest.AssertNotExistsBean(t, &pull_model.AutoMerge{PullID: pr.ID}) + }) +} + +func TestPullAutoMergeAfterCommitStatusSucceedAndApprovalForAgitFlow(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + // create a pull request + baseAPITestContext := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + dstPath := t.TempDir() + + u.Path = baseAPITestContext.GitPath() + u.User = url.UserPassword("user2", userPassword) + + t.Run("Clone", doGitClone(dstPath, u)) + + err := os.WriteFile(path.Join(dstPath, "test_file"), []byte("## test content"), 0o666) + require.NoError(t, err) + + err = git.AddChanges(dstPath, true) + require.NoError(t, err) + + err = git.CommitChanges(dstPath, git.CommitChangesOptions{ + Committer: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Author: &git.Signature{ + Email: "user2@example.com", + Name: "user2", + When: time.Now(), + }, + Message: "Testing commit 1", + }) + require.NoError(t, err) + + stderrBuf := &bytes.Buffer{} + + err = git.NewCommand(git.DefaultContext, "push", "origin", "HEAD:refs/for/master", "-o"). + AddDynamicArguments(`topic=test/head2`). + AddArguments("-o"). + AddDynamicArguments(`title="create a test pull request with agit"`). + AddArguments("-o"). + AddDynamicArguments(`description="This PR is a test pull request which created with agit"`). + Run(&git.RunOpts{Dir: dstPath, Stderr: stderrBuf}) + require.NoError(t, err) + + assert.Contains(t, stderrBuf.String(), setting.AppURL+"user2/repo1/pulls/6") + + baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"}) + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ + Flow: issues_model.PullRequestFlowAGit, + BaseRepoID: baseRepo.ID, + BaseBranch: "master", + HeadRepoID: baseRepo.ID, + HeadBranch: "user2/test/head2", + }) + + session := loginUser(t, "user1") + // add protected branch for commit status + csrf := GetCSRF(t, session, "/user2/repo1/settings/branches") + // Change master branch to protected + req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{ + "_csrf": csrf, + "rule_name": "master", + "enable_push": "true", + "enable_status_check": "true", + "status_check_contexts": "gitea/actions", + "required_approvals": "1", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + // first time insert automerge record, return true + scheduled, err := automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test") + require.NoError(t, err) + assert.True(t, scheduled) + + // second time insert automerge record, return false because it does exist + scheduled, err = automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test") + require.Error(t, err) + assert.False(t, scheduled) + + // reload pr again + pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID}) + assert.False(t, pr.HasMerged) + assert.Empty(t, pr.MergedCommitID) + + // update commit status to success, then it should be merged automatically + baseGitRepo, err := gitrepo.OpenRepository(db.DefaultContext, baseRepo) + require.NoError(t, err) + sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName()) + require.NoError(t, err) + masterCommitID, err := baseGitRepo.GetBranchCommitID("master") + require.NoError(t, err) + baseGitRepo.Close() + defer func() { + testResetRepo(t, baseRepo.RepoPath(), "master", masterCommitID) + }() + + err = commitstatus_service.CreateCommitStatus(db.DefaultContext, baseRepo, user1, sha, &git_model.CommitStatus{ + State: api.CommitStatusSuccess, + TargetURL: "https://gitea.com", + Context: "gitea/actions", + }) + require.NoError(t, err) + + time.Sleep(2 * time.Second) + + // reload pr again + pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID}) + assert.False(t, pr.HasMerged) + assert.Empty(t, pr.MergedCommitID) + + // approve the PR from non-author + approveSession := loginUser(t, "user1") + req = NewRequest(t, "GET", fmt.Sprintf("/user2/repo1/pulls/%d", pr.Index)) + resp := approveSession.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + testSubmitReview(t, approveSession, htmlDoc.GetCSRF(), "user2", "repo1", strconv.Itoa(int(pr.Index)), sha, "approve", http.StatusOK) + + time.Sleep(2 * time.Second) + + // realod pr again + pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID}) + assert.True(t, pr.HasMerged) + assert.NotEmpty(t, pr.MergedCommitID) + + unittest.AssertNotExistsBean(t, &pull_model.AutoMerge{PullID: pr.ID}) + }) +} + +func TestPullDeleteBranchPerms(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + user2Session := loginUser(t, "user2") + user4Session := loginUser(t, "user4") + testRepoFork(t, user4Session, "user2", "repo1", "user4", "repo1") + testEditFileToNewBranch(t, user2Session, "user2", "repo1", "master", "base-pr", "README.md", "Hello, World\n(Edited - base PR)\n") + + req := NewRequestWithValues(t, "POST", "/user4/repo1/compare/master...user2/repo1:base-pr", map[string]string{ + "_csrf": GetCSRF(t, user4Session, "/user4/repo1/compare/master...user2/repo1:base-pr"), + "title": "Testing PR", + }) + resp := user4Session.MakeRequest(t, req, http.StatusOK) + elem := strings.Split(test.RedirectURL(resp), "/") + + req = NewRequestWithValues(t, "POST", "/user4/repo1/pulls/"+elem[4]+"/merge", map[string]string{ + "_csrf": GetCSRF(t, user4Session, "/user4/repo1/pulls/"+elem[4]), + "do": "merge", + "delete_branch_after_merge": "on", + }) + user4Session.MakeRequest(t, req, http.StatusOK) + + flashCookie := user4Session.GetCookie(forgejo_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "error%3DYou%2Bdon%2527t%2Bhave%2Bpermission%2Bto%2Bdelete%2Bthe%2Bhead%2Bbranch.", flashCookie.Value) + + // Check that the branch still exist. + req = NewRequest(t, "GET", "/user2/repo1/src/branch/base-pr") + user4Session.MakeRequest(t, req, http.StatusOK) + }) +} diff --git a/tests/integration/pull_reopen_test.go b/tests/integration/pull_reopen_test.go new file mode 100644 index 0000000..e510d59 --- /dev/null +++ b/tests/integration/pull_reopen_test.go @@ -0,0 +1,216 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/translation" + gitea_context "code.gitea.io/gitea/services/context" + issue_service "code.gitea.io/gitea/services/issue" + pull_service "code.gitea.io/gitea/services/pull" + repo_service "code.gitea.io/gitea/services/repository" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPullrequestReopen(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + org26 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26}) + + // Create an base repository. + baseRepo, _, f := tests.CreateDeclarativeRepo(t, user2, "reopen-base", + []unit_model.Type{unit_model.TypePullRequests}, nil, nil, + ) + defer f() + + // Create a new branch on the base branch, so it can be deleted later. + _, err := files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, user2, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "update", + TreePath: "README.md", + ContentReader: strings.NewReader("New README.md"), + }, + }, + Message: "Modify README for base", + OldBranch: "main", + NewBranch: "base-branch", + Author: &files_service.IdentityOptions{ + Name: user2.Name, + Email: user2.Email, + }, + Committer: &files_service.IdentityOptions{ + Name: user2.Name, + Email: user2.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + require.NoError(t, err) + + // Create an head repository. + headRepo, err := repo_service.ForkRepositoryAndUpdates(git.DefaultContext, user2, org26, repo_service.ForkRepoOptions{ + BaseRepo: baseRepo, + Name: "reopen-head", + }) + require.NoError(t, err) + assert.NotEmpty(t, headRepo) + + // Add a change to the head repository, so a pull request can be opened. + _, err = files_service.ChangeRepoFiles(git.DefaultContext, headRepo, user2, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "update", + TreePath: "README.md", + ContentReader: strings.NewReader("Updated README.md"), + }, + }, + Message: "Modify README for head", + OldBranch: "main", + NewBranch: "head-branch", + Author: &files_service.IdentityOptions{ + Name: user2.Name, + Email: user2.Email, + }, + Committer: &files_service.IdentityOptions{ + Name: user2.Name, + Email: user2.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + require.NoError(t, err) + + // Create the pull request. + pullIssue := &issues_model.Issue{ + RepoID: baseRepo.ID, + Title: "Testing reopen functionality", + PosterID: user2.ID, + Poster: user2, + IsPull: true, + } + pullRequest := &issues_model.PullRequest{ + HeadRepoID: headRepo.ID, + BaseRepoID: baseRepo.ID, + HeadBranch: "head-branch", + BaseBranch: "base-branch", + HeadRepo: headRepo, + BaseRepo: baseRepo, + Type: issues_model.PullRequestGitea, + } + err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil) + require.NoError(t, err) + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "Testing reopen functionality"}) + + // Close the PR. + err = issue_service.ChangeStatus(db.DefaultContext, issue, user2, "", true) + require.NoError(t, err) + + session := loginUser(t, "user2") + + reopenPR := func(t *testing.T, expectedStatus int) *httptest.ResponseRecorder { + t.Helper() + + link := fmt.Sprintf("%s/pulls/%d/comments", baseRepo.FullName(), issue.Index) + req := NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": GetCSRF(t, session, fmt.Sprintf("%s/pulls/%d", baseRepo.FullName(), issue.Index)), + "status": "reopen", + }) + return session.MakeRequest(t, req, expectedStatus) + } + + restoreBranch := func(t *testing.T, repoName, branchName string, branchID int64) { + t.Helper() + + link := fmt.Sprintf("/%s/branches", repoName) + req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/restore?branch_id=%d&name=%s", link, branchID, branchName), map[string]string{ + "_csrf": GetCSRF(t, session, link), + }) + session.MakeRequest(t, req, http.StatusOK) + + flashCookie := session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.Contains(t, flashCookie.Value, "success%3DBranch%2B%2522"+branchName+"%2522%2Bhas%2Bbeen%2Brestored.") + } + + deleteBranch := func(t *testing.T, repoName, branchName string) { + t.Helper() + + link := fmt.Sprintf("/%s/branches", repoName) + req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/delete?name=%s", link, branchName), map[string]string{ + "_csrf": GetCSRF(t, session, link), + }) + session.MakeRequest(t, req, http.StatusOK) + + flashCookie := session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.Contains(t, flashCookie.Value, "success%3DBranch%2B%2522"+branchName+"%2522%2Bhas%2Bbeen%2Bdeleted.") + } + + type errorJSON struct { + Error string `json:"errorMessage"` + } + + t.Run("Base branch deleted", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{Name: "base-branch", RepoID: baseRepo.ID}) + defer func() { + restoreBranch(t, baseRepo.FullName(), branch.Name, branch.ID) + }() + + deleteBranch(t, baseRepo.FullName(), branch.Name) + resp := reopenPR(t, http.StatusBadRequest) + + var errorResp errorJSON + DecodeJSON(t, resp, &errorResp) + assert.EqualValues(t, translation.NewLocale("en-US").Tr("repo.pulls.reopen_failed.base_branch"), errorResp.Error) + }) + + t.Run("Head branch deleted", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{Name: "head-branch", RepoID: headRepo.ID}) + defer func() { + restoreBranch(t, headRepo.FullName(), branch.Name, branch.ID) + }() + + deleteBranch(t, headRepo.FullName(), branch.Name) + resp := reopenPR(t, http.StatusBadRequest) + + var errorResp errorJSON + DecodeJSON(t, resp, &errorResp) + assert.EqualValues(t, translation.NewLocale("en-US").Tr("repo.pulls.reopen_failed.head_branch"), errorResp.Error) + }) + + t.Run("Normal", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + reopenPR(t, http.StatusOK) + }) + }) +} diff --git a/tests/integration/pull_request_task_test.go b/tests/integration/pull_request_task_test.go new file mode 100644 index 0000000..4366d97 --- /dev/null +++ b/tests/integration/pull_request_task_test.go @@ -0,0 +1,109 @@ +// Copyright 2024 The Forgejo Authors +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/timeutil" + pull_service "code.gitea.io/gitea/services/pull" + repo_service "code.gitea.io/gitea/services/repository" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPullRequestSynchronized(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // unmerged pull request of user2/repo1 from branch2 to master + pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}) + // tip of tests/gitea-repositories-meta/user2/repo1 branch2 + pull.HeadCommitID = "985f0301dba5e7b34be866819cd15ad3d8f508ee" + pull.LoadIssue(db.DefaultContext) + pull.Issue.Created = timeutil.TimeStampNanoNow() + issues_model.UpdateIssueCols(db.DefaultContext, pull.Issue, "created") + + require.Equal(t, pull.HeadRepoID, pull.BaseRepoID) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pull.HeadRepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + for _, testCase := range []struct { + name string + timeNano int64 + expected bool + }{ + { + name: "AddTestPullRequestTask process PR", + timeNano: int64(pull.Issue.Created), + expected: true, + }, + { + name: "AddTestPullRequestTask skip PR", + timeNano: 0, + expected: false, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + logChecker, cleanup := test.NewLogChecker(log.DEFAULT, log.TRACE) + logChecker.Filter("Updating PR").StopMark("TestPullRequest ") + defer cleanup() + + opt := &repo_module.PushUpdateOptions{ + PusherID: owner.ID, + PusherName: owner.Name, + RepoUserName: owner.Name, + RepoName: repo.Name, + RefFullName: git.RefName("refs/heads/branch2"), + OldCommitID: pull.HeadCommitID, + NewCommitID: pull.HeadCommitID, + TimeNano: testCase.timeNano, + } + require.NoError(t, repo_service.PushUpdate(opt)) + logFiltered, logStopped := logChecker.Check(5 * time.Second) + assert.True(t, logStopped) + assert.Equal(t, testCase.expected, logFiltered[0]) + }) + } + + for _, testCase := range []struct { + name string + olderThan int64 + expected bool + }{ + { + name: "TestPullRequest process PR", + olderThan: int64(pull.Issue.Created), + expected: true, + }, + { + name: "TestPullRequest skip PR", + olderThan: int64(pull.Issue.Created) - 1, + expected: false, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + logChecker, cleanup := test.NewLogChecker(log.DEFAULT, log.TRACE) + logChecker.Filter("Updating PR").StopMark("TestPullRequest ") + defer cleanup() + + pull_service.TestPullRequest(context.Background(), owner, repo.ID, testCase.olderThan, "branch2", true, pull.HeadCommitID, pull.HeadCommitID) + logFiltered, logStopped := logChecker.Check(5 * time.Second) + assert.True(t, logStopped) + assert.Equal(t, testCase.expected, logFiltered[0]) + }) + } +} diff --git a/tests/integration/pull_review_test.go b/tests/integration/pull_review_test.go new file mode 100644 index 0000000..fc3e9be --- /dev/null +++ b/tests/integration/pull_review_test.go @@ -0,0 +1,519 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "path" + "strconv" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/test" + issue_service "code.gitea.io/gitea/services/issue" + repo_service "code.gitea.io/gitea/services/repository" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPullView_ReviewerMissed(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user1") + + req := NewRequest(t, "GET", "/pulls") + resp := session.MakeRequest(t, req, http.StatusOK) + assert.True(t, test.IsNormalPageCompleted(resp.Body.String())) + + req = NewRequest(t, "GET", "/user2/repo1/pulls/3") + resp = session.MakeRequest(t, req, http.StatusOK) + assert.True(t, test.IsNormalPageCompleted(resp.Body.String())) + + // if some reviews are missing, the page shouldn't fail + reviews, err := issues_model.FindReviews(db.DefaultContext, issues_model.FindReviewOptions{ + IssueID: 2, + }) + require.NoError(t, err) + for _, r := range reviews { + require.NoError(t, issues_model.DeleteReview(db.DefaultContext, r)) + } + req = NewRequest(t, "GET", "/user2/repo1/pulls/2") + resp = session.MakeRequest(t, req, http.StatusOK) + assert.True(t, test.IsNormalPageCompleted(resp.Body.String())) +} + +func loadComment(t *testing.T, commentID string) *issues_model.Comment { + t.Helper() + id, err := strconv.ParseInt(commentID, 10, 64) + require.NoError(t, err) + return unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: id}) +} + +func TestPullView_ResolveInvalidatedReviewComment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user1") + + req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files") + session.MakeRequest(t, req, http.StatusOK) + + t.Run("single outdated review (line 1)", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files/reviews/new_comment") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/comments", map[string]string{ + "_csrf": doc.GetInputValueByName("_csrf"), + "origin": doc.GetInputValueByName("origin"), + "latest_commit_id": doc.GetInputValueByName("latest_commit_id"), + "side": "proposed", + "line": "1", + "path": "iso-8859-1.txt", + "diff_start_cid": doc.GetInputValueByName("diff_start_cid"), + "diff_end_cid": doc.GetInputValueByName("diff_end_cid"), + "diff_base_cid": doc.GetInputValueByName("diff_base_cid"), + "content": "nitpicking comment", + "pending_review": "", + }) + session.MakeRequest(t, req, http.StatusOK) + + req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/submit", map[string]string{ + "_csrf": doc.GetInputValueByName("_csrf"), + "commit_id": doc.GetInputValueByName("latest_commit_id"), + "content": "looks good", + "type": "comment", + }) + session.MakeRequest(t, req, http.StatusOK) + + // retrieve comment_id by reloading the comment page + req = NewRequest(t, "GET", "/user2/repo1/pulls/3") + resp = session.MakeRequest(t, req, http.StatusOK) + doc = NewHTMLParser(t, resp.Body) + commentID, ok := doc.Find(`[data-action="Resolve"]`).Attr("data-comment-id") + assert.True(t, ok) + + // adjust the database to mark the comment as invalidated + // (to invalidate it properly, one should push a commit which should trigger this logic, + // in the meantime, use this quick-and-dirty trick) + comment := loadComment(t, commentID) + require.NoError(t, issues_model.UpdateCommentInvalidate(context.Background(), &issues_model.Comment{ + ID: comment.ID, + Invalidated: true, + })) + + req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/resolve_conversation", map[string]string{ + "_csrf": doc.GetInputValueByName("_csrf"), + "origin": "timeline", + "action": "Resolve", + "comment_id": commentID, + }) + resp = session.MakeRequest(t, req, http.StatusOK) + + // even on template error, the page returns HTTP 200 + // count the comments to ensure success. + doc = NewHTMLParser(t, resp.Body) + assert.Len(t, doc.Find(`.comments > .comment`).Nodes, 1) + }) + + t.Run("outdated and newer review (line 2)", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files/reviews/new_comment") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + var firstReviewID int64 + { + // first (outdated) review + req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/comments", map[string]string{ + "_csrf": doc.GetInputValueByName("_csrf"), + "origin": doc.GetInputValueByName("origin"), + "latest_commit_id": doc.GetInputValueByName("latest_commit_id"), + "side": "proposed", + "line": "2", + "path": "iso-8859-1.txt", + "diff_start_cid": doc.GetInputValueByName("diff_start_cid"), + "diff_end_cid": doc.GetInputValueByName("diff_end_cid"), + "diff_base_cid": doc.GetInputValueByName("diff_base_cid"), + "content": "nitpicking comment", + "pending_review": "", + }) + session.MakeRequest(t, req, http.StatusOK) + + req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/submit", map[string]string{ + "_csrf": doc.GetInputValueByName("_csrf"), + "commit_id": doc.GetInputValueByName("latest_commit_id"), + "content": "looks good", + "type": "comment", + }) + session.MakeRequest(t, req, http.StatusOK) + + // retrieve comment_id by reloading the comment page + req = NewRequest(t, "GET", "/user2/repo1/pulls/3") + resp = session.MakeRequest(t, req, http.StatusOK) + doc = NewHTMLParser(t, resp.Body) + commentID, ok := doc.Find(`[data-action="Resolve"]`).Attr("data-comment-id") + assert.True(t, ok) + + // adjust the database to mark the comment as invalidated + // (to invalidate it properly, one should push a commit which should trigger this logic, + // in the meantime, use this quick-and-dirty trick) + comment := loadComment(t, commentID) + require.NoError(t, issues_model.UpdateCommentInvalidate(context.Background(), &issues_model.Comment{ + ID: comment.ID, + Invalidated: true, + })) + firstReviewID = comment.ReviewID + assert.NotZero(t, firstReviewID) + } + + // ID of the first comment for the second (up-to-date) review + var commentID string + + { + // second (up-to-date) review on the same line + // make a second review + req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/comments", map[string]string{ + "_csrf": doc.GetInputValueByName("_csrf"), + "origin": doc.GetInputValueByName("origin"), + "latest_commit_id": doc.GetInputValueByName("latest_commit_id"), + "side": "proposed", + "line": "2", + "path": "iso-8859-1.txt", + "diff_start_cid": doc.GetInputValueByName("diff_start_cid"), + "diff_end_cid": doc.GetInputValueByName("diff_end_cid"), + "diff_base_cid": doc.GetInputValueByName("diff_base_cid"), + "content": "nitpicking comment", + "pending_review": "", + }) + session.MakeRequest(t, req, http.StatusOK) + + req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/submit", map[string]string{ + "_csrf": doc.GetInputValueByName("_csrf"), + "commit_id": doc.GetInputValueByName("latest_commit_id"), + "content": "looks better", + "type": "comment", + }) + session.MakeRequest(t, req, http.StatusOK) + + // retrieve comment_id by reloading the comment page + req = NewRequest(t, "GET", "/user2/repo1/pulls/3") + resp = session.MakeRequest(t, req, http.StatusOK) + doc = NewHTMLParser(t, resp.Body) + + commentIDs := doc.Find(`[data-action="Resolve"]`).Map(func(i int, elt *goquery.Selection) string { + v, _ := elt.Attr("data-comment-id") + return v + }) + assert.Len(t, commentIDs, 2) // 1 for the outdated review, 1 for the current review + + // check that the first comment is for the previous review + comment := loadComment(t, commentIDs[0]) + assert.Equal(t, comment.ReviewID, firstReviewID) + + // check that the second comment is for a different review + comment = loadComment(t, commentIDs[1]) + assert.NotZero(t, comment.ReviewID) + assert.NotEqual(t, comment.ReviewID, firstReviewID) + + commentID = commentIDs[1] // save commentID for later + } + + req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/resolve_conversation", map[string]string{ + "_csrf": doc.GetInputValueByName("_csrf"), + "origin": "timeline", + "action": "Resolve", + "comment_id": commentID, + }) + resp = session.MakeRequest(t, req, http.StatusOK) + + // even on template error, the page returns HTTP 200 + // count the comments to ensure success. + doc = NewHTMLParser(t, resp.Body) + comments := doc.Find(`.comments > .comment`) + assert.Len(t, comments.Nodes, 1) // the outdated comment belongs to another review and should not be shown + }) + + t.Run("Files Changed tab", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + for _, c := range []struct { + style, outdated string + expectedCount int + }{ + {"unified", "true", 3}, // 1 comment on line 1 + 2 comments on line 3 + {"unified", "false", 1}, // 1 comment on line 3 is not outdated + {"split", "true", 3}, // 1 comment on line 1 + 2 comments on line 3 + {"split", "false", 1}, // 1 comment on line 3 is not outdated + } { + t.Run(c.style+"+"+c.outdated, func(t *testing.T) { + req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files?style="+c.style+"&show-outdated="+c.outdated) + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + comments := doc.Find(`.comments > .comment`) + assert.Len(t, comments.Nodes, c.expectedCount) + }) + } + }) + + t.Run("Conversation tab", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequest(t, "GET", "/user2/repo1/pulls/3") + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + comments := doc.Find(`.comments > .comment`) + assert.Len(t, comments.Nodes, 3) // 1 comment on line 1 + 2 comments on line 3 + }) +} + +func TestPullView_CodeOwner(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // Create the repo. + repo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{ + Name: "test_codeowner", + Readme: "Default", + AutoInit: true, + ObjectFormatName: git.Sha1ObjectFormat.Name(), + DefaultBranch: "master", + }) + require.NoError(t, err) + + // add CODEOWNERS to default branch + _, err = files_service.ChangeRepoFiles(db.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ + OldBranch: repo.DefaultBranch, + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "CODEOWNERS", + ContentReader: strings.NewReader("README.md @user5\n"), + }, + }, + }) + require.NoError(t, err) + + t.Run("First Pull Request", func(t *testing.T) { + // create a new branch to prepare for pull request + _, err = files_service.ChangeRepoFiles(db.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ + NewBranch: "codeowner-basebranch", + Files: []*files_service.ChangeRepoFile{ + { + Operation: "update", + TreePath: "README.md", + ContentReader: strings.NewReader("# This is a new project\n"), + }, + }, + }) + require.NoError(t, err) + + // Create a pull request. + session := loginUser(t, "user2") + testPullCreate(t, session, "user2", "test_codeowner", false, repo.DefaultBranch, "codeowner-basebranch", "Test Pull Request") + + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadRepoID: repo.ID, HeadBranch: "codeowner-basebranch"}) + unittest.AssertExistsIf(t, true, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 5}) + require.NoError(t, pr.LoadIssue(db.DefaultContext)) + + err := issue_service.ChangeTitle(db.DefaultContext, pr.Issue, user2, "[WIP] Test Pull Request") + require.NoError(t, err) + prUpdated1 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID}) + require.NoError(t, prUpdated1.LoadIssue(db.DefaultContext)) + assert.EqualValues(t, "[WIP] Test Pull Request", prUpdated1.Issue.Title) + + err = issue_service.ChangeTitle(db.DefaultContext, prUpdated1.Issue, user2, "Test Pull Request2") + require.NoError(t, err) + prUpdated2 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID}) + require.NoError(t, prUpdated2.LoadIssue(db.DefaultContext)) + assert.EqualValues(t, "Test Pull Request2", prUpdated2.Issue.Title) + }) + + // change the default branch CODEOWNERS file to change README.md's codeowner + _, err = files_service.ChangeRepoFiles(db.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "update", + TreePath: "CODEOWNERS", + ContentReader: strings.NewReader("README.md @user8\n"), + }, + }, + }) + require.NoError(t, err) + + t.Run("Second Pull Request", func(t *testing.T) { + // create a new branch to prepare for pull request + _, err = files_service.ChangeRepoFiles(db.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ + NewBranch: "codeowner-basebranch2", + Files: []*files_service.ChangeRepoFile{ + { + Operation: "update", + TreePath: "README.md", + ContentReader: strings.NewReader("# This is a new project2\n"), + }, + }, + }) + require.NoError(t, err) + + // Create a pull request. + session := loginUser(t, "user2") + testPullCreate(t, session, "user2", "test_codeowner", false, repo.DefaultBranch, "codeowner-basebranch2", "Test Pull Request2") + + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadBranch: "codeowner-basebranch2"}) + unittest.AssertExistsIf(t, true, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 8}) + }) + + t.Run("Forked Repo Pull Request", func(t *testing.T) { + user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + forkedRepo, err := repo_service.ForkRepositoryAndUpdates(db.DefaultContext, user2, user5, repo_service.ForkRepoOptions{ + BaseRepo: repo, + Name: "test_codeowner_fork", + }) + require.NoError(t, err) + + // create a new branch to prepare for pull request + _, err = files_service.ChangeRepoFiles(db.DefaultContext, forkedRepo, user5, &files_service.ChangeRepoFilesOptions{ + NewBranch: "codeowner-basebranch-forked", + Files: []*files_service.ChangeRepoFile{ + { + Operation: "update", + TreePath: "README.md", + ContentReader: strings.NewReader("# This is a new forked project\n"), + }, + }, + }) + require.NoError(t, err) + + session := loginUser(t, "user5") + + // create a pull request on the forked repository, code reviewers should not be mentioned + testPullCreateDirectly(t, session, "user5", "test_codeowner_fork", forkedRepo.DefaultBranch, "", "", "codeowner-basebranch-forked", "Test Pull Request on Forked Repository") + + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: forkedRepo.ID, HeadBranch: "codeowner-basebranch-forked"}) + unittest.AssertExistsIf(t, false, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 8}) + + // create a pull request to base repository, code reviewers should be mentioned + testPullCreateDirectly(t, session, repo.OwnerName, repo.Name, repo.DefaultBranch, forkedRepo.OwnerName, forkedRepo.Name, "codeowner-basebranch-forked", "Test Pull Request3") + + pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadRepoID: forkedRepo.ID, HeadBranch: "codeowner-basebranch-forked"}) + unittest.AssertExistsIf(t, true, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 8}) + }) + }) +} + +func TestPullView_GivenApproveOrRejectReviewOnClosedPR(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + user1Session := loginUser(t, "user1") + user2Session := loginUser(t, "user2") + + // Have user1 create a fork of repo1. + testRepoFork(t, user1Session, "user2", "repo1", "user1", "repo1") + + baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"}) + forkedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user1", Name: "repo1"}) + baseGitRepo, err := gitrepo.OpenRepository(db.DefaultContext, baseRepo) + require.NoError(t, err) + defer baseGitRepo.Close() + + t.Run("Submit approve/reject review on merged PR", func(t *testing.T) { + // Create a merged PR (made by user1) in the upstream repo1. + testEditFile(t, user1Session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n") + resp := testPullCreate(t, user1Session, "user1", "repo1", false, "master", "master", "This is a pull title") + elem := strings.Split(test.RedirectURL(resp), "/") + assert.EqualValues(t, "pulls", elem[3]) + testPullMerge(t, user1Session, elem[1], elem[2], elem[4], repo_model.MergeStyleMerge, false) + + // Get the commit SHA + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ + BaseRepoID: baseRepo.ID, + BaseBranch: "master", + HeadRepoID: forkedRepo.ID, + HeadBranch: "master", + }) + sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName()) + require.NoError(t, err) + + // Grab the CSRF token. + req := NewRequest(t, "GET", path.Join(elem[1], elem[2], "pulls", elem[4])) + resp = user2Session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + // Submit an approve review on the PR. + testSubmitReview(t, user2Session, htmlDoc.GetCSRF(), "user2", "repo1", elem[4], sha, "approve", http.StatusOK) + + // Submit a reject review on the PR. + testSubmitReview(t, user2Session, htmlDoc.GetCSRF(), "user2", "repo1", elem[4], sha, "reject", http.StatusOK) + }) + + t.Run("Submit approve/reject review on closed PR", func(t *testing.T) { + // Created a closed PR (made by user1) in the upstream repo1. + testEditFileToNewBranch(t, user1Session, "user1", "repo1", "master", "a-test-branch", "README.md", "Hello, World (Edited...again)\n") + resp := testPullCreate(t, user1Session, "user1", "repo1", false, "master", "a-test-branch", "This is a pull title") + elem := strings.Split(test.RedirectURL(resp), "/") + assert.EqualValues(t, "pulls", elem[3]) + testIssueClose(t, user1Session, elem[1], elem[2], elem[4]) + + // Get the commit SHA + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ + BaseRepoID: baseRepo.ID, + BaseBranch: "master", + HeadRepoID: forkedRepo.ID, + HeadBranch: "a-test-branch", + }) + sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName()) + require.NoError(t, err) + + // Grab the CSRF token. + req := NewRequest(t, "GET", path.Join(elem[1], elem[2], "pulls", elem[4])) + resp = user2Session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + // Submit an approve review on the PR. + testSubmitReview(t, user2Session, htmlDoc.GetCSRF(), "user2", "repo1", elem[4], sha, "approve", http.StatusOK) + + // Submit a reject review on the PR. + testSubmitReview(t, user2Session, htmlDoc.GetCSRF(), "user2", "repo1", elem[4], sha, "reject", http.StatusOK) + }) + }) +} + +func testSubmitReview(t *testing.T, session *TestSession, csrf, owner, repo, pullNumber, commitID, reviewType string, expectedSubmitStatus int) *httptest.ResponseRecorder { + options := map[string]string{ + "_csrf": csrf, + "commit_id": commitID, + "content": "test", + "type": reviewType, + } + + submitURL := path.Join(owner, repo, "pulls", pullNumber, "files", "reviews", "submit") + req := NewRequestWithValues(t, "POST", submitURL, options) + return session.MakeRequest(t, req, expectedSubmitStatus) +} + +func testIssueClose(t *testing.T, session *TestSession, owner, repo, issueNumber string) *httptest.ResponseRecorder { + req := NewRequest(t, "GET", path.Join(owner, repo, "pulls", issueNumber)) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + closeURL := path.Join(owner, repo, "issues", issueNumber, "comments") + + options := map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "status": "close", + } + + req = NewRequestWithValues(t, "POST", closeURL, options) + return session.MakeRequest(t, req, http.StatusOK) +} diff --git a/tests/integration/pull_status_test.go b/tests/integration/pull_status_test.go new file mode 100644 index 0000000..80eea34 --- /dev/null +++ b/tests/integration/pull_status_test.go @@ -0,0 +1,167 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "path" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + api "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" +) + +func TestPullCreate_CommitStatus(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFileToNewBranch(t, session, "user1", "repo1", "master", "status1", "README.md", "status1") + + url := path.Join("user1", "repo1", "compare", "master...status1") + req := NewRequestWithValues(t, "POST", url, + map[string]string{ + "_csrf": GetCSRF(t, session, url), + "title": "pull request from status1", + }, + ) + session.MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", "/user1/repo1/pulls") + resp := session.MakeRequest(t, req, http.StatusOK) + NewHTMLParser(t, resp.Body) + + // Request repository commits page + req = NewRequest(t, "GET", "/user1/repo1/pulls/1/commits") + resp = session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + // Get first commit URL + commitURL, exists := doc.doc.Find("#commits-table tbody tr td.sha a").Last().Attr("href") + assert.True(t, exists) + assert.NotEmpty(t, commitURL) + + commitID := path.Base(commitURL) + + statusList := []api.CommitStatusState{ + api.CommitStatusPending, + api.CommitStatusError, + api.CommitStatusFailure, + api.CommitStatusSuccess, + api.CommitStatusWarning, + } + + statesIcons := map[api.CommitStatusState]string{ + api.CommitStatusPending: "octicon-dot-fill", + api.CommitStatusSuccess: "octicon-check", + api.CommitStatusError: "gitea-exclamation", + api.CommitStatusFailure: "octicon-x", + api.CommitStatusWarning: "gitea-exclamation", + } + + testCtx := NewAPITestContext(t, "user1", "repo1", auth_model.AccessTokenScopeWriteRepository) + + // Update commit status, and check if icon is updated as well + for _, status := range statusList { + // Call API to add status for commit + t.Run("CreateStatus", doAPICreateCommitStatus(testCtx, commitID, api.CreateStatusOption{ + State: status, + TargetURL: "http://test.ci/", + Description: "", + Context: "testci", + })) + + req = NewRequest(t, "GET", "/user1/repo1/pulls/1/commits") + resp = session.MakeRequest(t, req, http.StatusOK) + doc = NewHTMLParser(t, resp.Body) + + commitURL, exists = doc.doc.Find("#commits-table tbody tr td.sha a").Last().Attr("href") + assert.True(t, exists) + assert.NotEmpty(t, commitURL) + assert.EqualValues(t, commitID, path.Base(commitURL)) + + cls, ok := doc.doc.Find("#commits-table tbody tr td.message .commit-status").Last().Attr("class") + assert.True(t, ok) + assert.Contains(t, cls, statesIcons[status]) + } + + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user1", Name: "repo1"}) + css := unittest.AssertExistsAndLoadBean(t, &git_model.CommitStatusSummary{RepoID: repo1.ID, SHA: commitID}) + assert.EqualValues(t, api.CommitStatusWarning, css.State) + }) +} + +func doAPICreateCommitStatus(ctx APITestContext, commitID string, data api.CreateStatusOption) func(*testing.T) { + return func(t *testing.T) { + req := NewRequestWithJSON( + t, + http.MethodPost, + fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", ctx.Username, ctx.Reponame, commitID), + data, + ).AddTokenAuth(ctx.Token) + if ctx.ExpectedCode != 0 { + ctx.Session.MakeRequest(t, req, ctx.ExpectedCode) + return + } + ctx.Session.MakeRequest(t, req, http.StatusCreated) + } +} + +func TestPullCreate_EmptyChangesWithDifferentCommits(t *testing.T) { + // Merge must continue if commits SHA are different, even if content is same + // Reason: gitflow and merging master back into develop, where is high possibility, there are no changes + // but just commit saying "Merge branch". And this meta commit can be also tagged, + // so we need to have this meta commit also in develop branch. + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFileToNewBranch(t, session, "user1", "repo1", "master", "status1", "README.md", "status1") + testEditFileToNewBranch(t, session, "user1", "repo1", "status1", "status1", "README.md", "# repo1\n\nDescription for repo1") + + url := path.Join("user1", "repo1", "compare", "master...status1") + req := NewRequestWithValues(t, "POST", url, + map[string]string{ + "_csrf": GetCSRF(t, session, url), + "title": "pull request from status1", + }, + ) + session.MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", "/user1/repo1/pulls/1") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + text := strings.TrimSpace(doc.doc.Find(".merge-section").Text()) + assert.Contains(t, text, "This pull request can be merged automatically.") + }) +} + +func TestPullCreate_EmptyChangesWithSameCommits(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testCreateBranch(t, session, "user1", "repo1", "branch/master", "status1", http.StatusSeeOther) + url := path.Join("user1", "repo1", "compare", "master...status1") + req := NewRequestWithValues(t, "POST", url, + map[string]string{ + "_csrf": GetCSRF(t, session, url), + "title": "pull request from status1", + }, + ) + session.MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", "/user1/repo1/pulls/1") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + text := strings.TrimSpace(doc.doc.Find(".merge-section").Text()) + assert.Contains(t, text, "This branch is already included in the target branch. There is nothing to merge.") + }) +} diff --git a/tests/integration/pull_summary_test.go b/tests/integration/pull_summary_test.go new file mode 100644 index 0000000..75c1272 --- /dev/null +++ b/tests/integration/pull_summary_test.go @@ -0,0 +1,65 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "path" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPullSummaryCommits(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + testUser := "user2" + testRepo := "repo1" + branchOld := "master" + branchNew := "new-branch" + session := loginUser(t, testUser) + + // Create a branch with commit, open a PR and see if the summary is displayed correctly for 1 commit + testEditFileToNewBranch(t, session, testUser, testRepo, branchOld, branchNew, "README.md", "test of pull summary") + url := path.Join(testUser, testRepo, "compare", branchOld+"..."+branchNew) + req := NewRequestWithValues(t, "POST", url, + map[string]string{ + "_csrf": GetCSRF(t, session, url), + "title": "1st pull request to test summary", + }, + ) + session.MakeRequest(t, req, http.StatusOK) + testPullSummaryCommits(t, session, testUser, testRepo, "6", "wants to merge 1 commit") + + // Merge the PR and see if the summary is displayed correctly for 1 commit + testPullMerge(t, session, testUser, testRepo, "6", "merge", true) + testPullSummaryCommits(t, session, testUser, testRepo, "6", "merged 1 commit") + + // Create a branch with 2 commits, open a PR and see if the summary is displayed correctly for 2 commits + testEditFileToNewBranch(t, session, testUser, testRepo, branchOld, branchNew, "README.md", "test of pull summary (the 2nd)") + testEditFile(t, session, testUser, testRepo, branchNew, "README.md", "test of pull summary (the 3rd)") + req = NewRequestWithValues(t, "POST", url, + map[string]string{ + "_csrf": GetCSRF(t, session, url), + "title": "2nd pull request to test summary", + }, + ) + session.MakeRequest(t, req, http.StatusOK) + testPullSummaryCommits(t, session, testUser, testRepo, "7", "wants to merge 2 commits") + + // Merge the PR and see if the summary is displayed correctly for 2 commits + testPullMerge(t, session, testUser, testRepo, "7", "merge", true) + testPullSummaryCommits(t, session, testUser, testRepo, "7", "merged 2 commits") + }) +} + +func testPullSummaryCommits(t *testing.T, session *TestSession, user, repo, pullNum, expectedSummary string) { + t.Helper() + req := NewRequest(t, "GET", path.Join(user, repo, "pulls", pullNum)) + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + text := strings.TrimSpace(doc.doc.Find(".pull-desc").Text()) + assert.Contains(t, text, expectedSummary) +} diff --git a/tests/integration/pull_test.go b/tests/integration/pull_test.go new file mode 100644 index 0000000..10dbad2 --- /dev/null +++ b/tests/integration/pull_test.go @@ -0,0 +1,67 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestViewPulls(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/user2/repo1/pulls") + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + search := htmlDoc.doc.Find(".list-header-search > .search > .input > input") + placeholder, _ := search.Attr("placeholder") + assert.Equal(t, "Search pulls...", placeholder) +} + +func TestPullManuallyMergeWarning(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + + warningMessage := `Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.` + t.Run("Autodetect disabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/pulls/3") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + mergeInstructions := htmlDoc.Find("#merge-instructions").Text() + assert.Contains(t, mergeInstructions, warningMessage) + }) + + pullRequestUnit := unittest.AssertExistsAndLoadBean(t, &repo_model.RepoUnit{RepoID: 1, Type: unit.TypePullRequests}) + config := pullRequestUnit.PullRequestsConfig() + config.AutodetectManualMerge = true + _, err := db.GetEngine(db.DefaultContext).ID(pullRequestUnit.ID).Cols("config").Update(pullRequestUnit) + require.NoError(t, err) + + t.Run("Autodetect enabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/pulls/3") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + mergeInstructions := htmlDoc.Find("#merge-instructions").Text() + assert.NotContains(t, mergeInstructions, warningMessage) + }) +} diff --git a/tests/integration/pull_update_test.go b/tests/integration/pull_update_test.go new file mode 100644 index 0000000..08041f0 --- /dev/null +++ b/tests/integration/pull_update_test.go @@ -0,0 +1,175 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "strings" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + pull_service "code.gitea.io/gitea/services/pull" + repo_service "code.gitea.io/gitea/services/repository" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIPullUpdate(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + // Create PR to test + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + org26 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26}) + pr := createOutdatedPR(t, user, org26) + + // Test GetDiverging + diffCount, err := pull_service.GetDiverging(git.DefaultContext, pr) + require.NoError(t, err) + assert.EqualValues(t, 1, diffCount.Behind) + assert.EqualValues(t, 1, diffCount.Ahead) + require.NoError(t, pr.LoadBaseRepo(db.DefaultContext)) + require.NoError(t, pr.LoadIssue(db.DefaultContext)) + + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/pulls/%d/update", pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Issue.Index). + AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusOK) + + // Test GetDiverging after update + diffCount, err = pull_service.GetDiverging(git.DefaultContext, pr) + require.NoError(t, err) + assert.EqualValues(t, 0, diffCount.Behind) + assert.EqualValues(t, 2, diffCount.Ahead) + }) +} + +func TestAPIPullUpdateByRebase(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + // Create PR to test + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + org26 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26}) + pr := createOutdatedPR(t, user, org26) + + // Test GetDiverging + diffCount, err := pull_service.GetDiverging(git.DefaultContext, pr) + require.NoError(t, err) + assert.EqualValues(t, 1, diffCount.Behind) + assert.EqualValues(t, 1, diffCount.Ahead) + require.NoError(t, pr.LoadBaseRepo(db.DefaultContext)) + require.NoError(t, pr.LoadIssue(db.DefaultContext)) + + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/pulls/%d/update?style=rebase", pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Issue.Index). + AddTokenAuth(token) + session.MakeRequest(t, req, http.StatusOK) + + // Test GetDiverging after update + diffCount, err = pull_service.GetDiverging(git.DefaultContext, pr) + require.NoError(t, err) + assert.EqualValues(t, 0, diffCount.Behind) + assert.EqualValues(t, 1, diffCount.Ahead) + }) +} + +func createOutdatedPR(t *testing.T, actor, forkOrg *user_model.User) *issues_model.PullRequest { + baseRepo, _, _ := tests.CreateDeclarativeRepo(t, actor, "repo-pr-update", nil, nil, nil) + + headRepo, err := repo_service.ForkRepositoryAndUpdates(git.DefaultContext, actor, forkOrg, repo_service.ForkRepoOptions{ + BaseRepo: baseRepo, + Name: "repo-pr-update", + Description: "desc", + }) + require.NoError(t, err) + assert.NotEmpty(t, headRepo) + + // create a commit on base Repo + _, err = files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, actor, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "File_A", + ContentReader: strings.NewReader("File A"), + }, + }, + Message: "Add File A", + OldBranch: "main", + NewBranch: "main", + Author: &files_service.IdentityOptions{ + Name: actor.Name, + Email: actor.Email, + }, + Committer: &files_service.IdentityOptions{ + Name: actor.Name, + Email: actor.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + require.NoError(t, err) + + // create a commit on head Repo + _, err = files_service.ChangeRepoFiles(git.DefaultContext, headRepo, actor, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "File_B", + ContentReader: strings.NewReader("File B"), + }, + }, + Message: "Add File on PR branch", + OldBranch: "main", + NewBranch: "newBranch", + Author: &files_service.IdentityOptions{ + Name: actor.Name, + Email: actor.Email, + }, + Committer: &files_service.IdentityOptions{ + Name: actor.Name, + Email: actor.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + require.NoError(t, err) + + // create Pull + pullIssue := &issues_model.Issue{ + RepoID: baseRepo.ID, + Title: "Test Pull -to-update-", + PosterID: actor.ID, + Poster: actor, + IsPull: true, + } + pullRequest := &issues_model.PullRequest{ + HeadRepoID: headRepo.ID, + BaseRepoID: baseRepo.ID, + HeadBranch: "newBranch", + BaseBranch: "main", + HeadRepo: headRepo, + BaseRepo: baseRepo, + Type: issues_model.PullRequestGitea, + } + err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil) + require.NoError(t, err) + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "Test Pull -to-update-"}) + require.NoError(t, issue.LoadPullRequest(db.DefaultContext)) + + return issue.PullRequest +} diff --git a/tests/integration/pull_wip_convert_test.go b/tests/integration/pull_wip_convert_test.go new file mode 100644 index 0000000..935636b --- /dev/null +++ b/tests/integration/pull_wip_convert_test.go @@ -0,0 +1,59 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "path" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPullWIPConvertSidebar(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + testRepo := "repo1" + branchOld := "master" + branchNew := "wip" + userOwner := "user2" + userUnrelated := "user4" + sessionOwner := loginUser(t, userOwner) // Owner of the repo. Expected to see the offers. + sessionUnrelated := loginUser(t, userUnrelated) // Unrelated user. Not expected to see the offers. + + // Create a branch with commit, open a PR and check who is seeing the Add WIP offering + testEditFileToNewBranch(t, sessionOwner, userOwner, testRepo, branchOld, branchNew, "README.md", "test of wip offering") + url := path.Join(userOwner, testRepo, "compare", branchOld+"..."+branchNew) + req := NewRequestWithValues(t, "POST", url, + map[string]string{ + "_csrf": GetCSRF(t, sessionOwner, url), + "title": "pull used for testing wip offering", + }, + ) + sessionOwner.MakeRequest(t, req, http.StatusOK) + testPullWIPConvertSidebar(t, sessionOwner, userOwner, testRepo, "6", "Still in progress? Add WIP: prefix") + testPullWIPConvertSidebar(t, sessionUnrelated, userOwner, testRepo, "6", "") + + // Add WIP: prefix and check who is seeing the Remove WIP offering + req = NewRequestWithValues(t, "POST", path.Join(userOwner, testRepo, "pulls/6/title"), + map[string]string{ + "_csrf": GetCSRF(t, sessionOwner, path.Join(userOwner, testRepo, "pulls/6")), + "title": "WIP: pull used for testing wip offering", + }, + ) + sessionOwner.MakeRequest(t, req, http.StatusOK) + testPullWIPConvertSidebar(t, sessionOwner, userOwner, testRepo, "6", "Ready for review? Remove WIP: prefix") + testPullWIPConvertSidebar(t, sessionUnrelated, userOwner, testRepo, "6", "") + }) +} + +func testPullWIPConvertSidebar(t *testing.T, session *TestSession, user, repo, pullNum, expected string) { + t.Helper() + req := NewRequest(t, "GET", path.Join(user, repo, "pulls", pullNum)) + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + text := strings.TrimSpace(doc.doc.Find(".toggle-wip a").Text()) + assert.Equal(t, expected, text) +} diff --git a/tests/integration/quota_use_test.go b/tests/integration/quota_use_test.go new file mode 100644 index 0000000..39c5c1a --- /dev/null +++ b/tests/integration/quota_use_test.go @@ -0,0 +1,1147 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + org_model "code.gitea.io/gitea/models/organization" + quota_model "code.gitea.io/gitea/models/quota" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + forgejo_context "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" + "code.gitea.io/gitea/tests" + + gouuid "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWebQuotaEnforcementRepoMigrate(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + env.RunVisitAndPostToPageTests(t, "/repo/migrate", &Payload{ + "repo_name": "migration-test", + "clone_addr": env.Users.Limited.Repo.Link() + ".git", + "service": fmt.Sprintf("%d", api.ForgejoService), + }, http.StatusOK) + }) +} + +func TestWebQuotaEnforcementRepoCreate(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + env.RunVisitAndPostToPageTests(t, "/repo/create", nil, http.StatusOK) + }) +} + +func TestWebQuotaEnforcementRepoFork(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + page := fmt.Sprintf("%s/fork", env.Users.Limited.Repo.Link()) + env.RunVisitAndPostToPageTests(t, page, &Payload{ + "repo_name": "fork-test", + }, http.StatusSeeOther) + }) +} + +func TestWebQuotaEnforcementIssueAttachment(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + // Uploading to our repo => 413 + env.As(t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + CreateIssueAttachment("test.txt"). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Uploading to the limited org repo => 413 + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Limited.Repo}). + CreateIssueAttachment("test.txt"). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Uploading to the unlimited org repo => 200 + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Unlimited.Repo}). + CreateIssueAttachment("test.txt"). + ExpectStatus(http.StatusOK) + }) +} + +func TestWebQuotaEnforcementMirrorSync(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + var mirrorRepo *repo_model.Repository + + env.As(t, env.Users.Limited). + WithoutQuota(func(ctx *quotaWebEnvAsContext) { + mirrorRepo = ctx.CreateMirror() + }). + With(Context{ + Repo: mirrorRepo, + Payload: &Payload{"action": "mirror-sync"}, + }). + PostToPage(mirrorRepo.Link() + "/settings"). + ExpectStatus(http.StatusOK). + ExpectFlashMessage("Quota exceeded, not pulling changes.") + }) +} + +func TestWebQuotaEnforcementRepoContentEditing(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + // We're only going to test the GET requests here, because the entire combo + // is covered by a route check. + + // Lets create a helper! + runCheck := func(t *testing.T, path string, successStatus int) { + t.Run("#"+path, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Uploading to a limited user's repo => 413 + env.As(t, env.Users.Limited). + VisitPage(env.Users.Limited.Repo.Link() + path). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Limited org => 413 + env.As(t, env.Users.Limited). + VisitPage(env.Orgs.Limited.Repo.Link() + path). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Unlimited org => 200 + env.As(t, env.Users.Limited). + VisitPage(env.Orgs.Unlimited.Repo.Link() + path). + ExpectStatus(successStatus) + }) + } + + paths := []string{ + "/_new/main", + "/_edit/main/README.md", + "/_delete/main", + "/_upload/main", + "/_diffpatch/main", + } + + for _, path := range paths { + runCheck(t, path, http.StatusOK) + } + + // Run another check for `_cherrypick`. It's cumbersome to dig out a valid + // commit id, so we'll use a fake, and treat 404 as a success: it's not 413, + // and that's all we care about for this test. + runCheck(t, "/_cherrypick/92cfceb39d57d914ed8b14d0e37643de0797ae56/main", http.StatusNotFound) + }) +} + +func TestWebQuotaEnforcementRepoBranches(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + t.Run("create", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + runTest := func(t *testing.T, path string) { + t.Run("#"+path, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + env.As(t, env.Users.Limited). + With(Context{Payload: &Payload{"new_branch_name": "quota"}}). + PostToRepoPage("/branches/_new" + path). + ExpectStatus(http.StatusRequestEntityTooLarge) + + env.As(t, env.Users.Limited). + With(Context{ + Payload: &Payload{"new_branch_name": "quota"}, + Repo: env.Orgs.Limited.Repo, + }). + PostToRepoPage("/branches/_new" + path). + ExpectStatus(http.StatusRequestEntityTooLarge) + + env.As(t, env.Users.Limited). + With(Context{ + Payload: &Payload{"new_branch_name": "quota"}, + Repo: env.Orgs.Unlimited.Repo, + }). + PostToRepoPage("/branches/_new" + path). + ExpectStatus(http.StatusNotFound) + }) + } + + // We're testing the first two against things that don't exist, so that + // all three consistently return 404 if no quota enforcement happens. + runTest(t, "/branch/no-such-branch") + runTest(t, "/tag/no-such-tag") + runTest(t, "/commit/92cfceb39d57d914ed8b14d0e37643de0797ae56") + }) + + t.Run("delete & restore", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + env.As(t, env.Users.Limited). + WithoutQuota(func(ctx *quotaWebEnvAsContext) { + ctx.With(Context{Payload: &Payload{"new_branch_name": "to-delete"}}). + PostToRepoPage("/branches/_new/branch/main"). + ExpectStatus(http.StatusSeeOther) + }) + + env.As(t, env.Users.Limited). + PostToRepoPage("/branches/delete?name=to-delete"). + ExpectStatus(http.StatusOK) + + env.As(t, env.Users.Limited). + PostToRepoPage("/branches/restore?name=to-delete"). + ExpectStatus(http.StatusOK) + }) + }) +} + +func TestWebQuotaEnforcementRepoReleases(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + env.RunVisitAndPostToRepoPageTests(t, "/releases/new", &Payload{ + "tag_name": "quota", + "tag_target": "main", + "title": "test release", + }, http.StatusSeeOther) + + t.Run("attachments", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Uploading to our repo => 413 + env.As(t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + CreateReleaseAttachment("test.txt"). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Uploading to the limited org repo => 413 + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Limited.Repo}). + CreateReleaseAttachment("test.txt"). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Uploading to the unlimited org repo => 200 + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Unlimited.Repo}). + CreateReleaseAttachment("test.txt"). + ExpectStatus(http.StatusOK) + }) + }) +} + +func TestWebQuotaEnforcementRepoPulls(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + // To create a pull request, we first fork the two limited repos into the + // unlimited org. + env.As(t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + ForkRepoInto(env.Orgs.Unlimited) + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Limited.Repo}). + ForkRepoInto(env.Orgs.Unlimited) + + // Then, create pull requests from the forks, back to the main repos + env.As(t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + CreatePullFrom(env.Orgs.Unlimited) + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Limited.Repo}). + CreatePullFrom(env.Orgs.Unlimited) + + // Trying to merge the pull request will fail for both, though, due to being + // over quota. + env.As(t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + With(Context{Payload: &Payload{"do": "merge"}}). + PostToRepoPage("/pulls/1/merge"). + ExpectStatus(http.StatusRequestEntityTooLarge) + + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Limited.Repo}). + With(Context{Payload: &Payload{"do": "merge"}}). + PostToRepoPage("/pulls/1/merge"). + ExpectStatus(http.StatusRequestEntityTooLarge) + }) +} + +func TestWebQuotaEnforcementRepoTransfer(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + t.Run("direct transfer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Trying to transfer the repository to a limited organization fails. + env.As(t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + With(Context{Payload: &Payload{ + "action": "transfer", + "repo_name": env.Users.Limited.Repo.FullName(), + "new_owner_name": env.Orgs.Limited.Org.Name, + }}). + PostToRepoPage("/settings"). + ExpectStatus(http.StatusOK). + ExpectFlashMessageContains("over quota", "The repository has not been transferred") + + // Trying to transfer to a different, also limited user, also fails. + env.As(t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + With(Context{Payload: &Payload{ + "action": "transfer", + "repo_name": env.Users.Limited.Repo.FullName(), + "new_owner_name": env.Users.Contributor.User.Name, + }}). + PostToRepoPage("/settings"). + ExpectStatus(http.StatusOK). + ExpectFlashMessageContains("over quota", "The repository has not been transferred") + }) + + t.Run("accept & reject", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Trying to transfer to a different user, with quota lifted, starts the transfer + env.As(t, env.Users.Contributor). + WithoutQuota(func(ctx *quotaWebEnvAsContext) { + env.As(ctx.t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + With(Context{Payload: &Payload{ + "action": "transfer", + "repo_name": env.Users.Limited.Repo.FullName(), + "new_owner_name": env.Users.Contributor.User.Name, + }}). + PostToRepoPage("/settings"). + ExpectStatus(http.StatusSeeOther). + ExpectFlashCookieContains("This repository has been marked for transfer and awaits confirmation") + }) + + // Trying to accept the transfer, with quota in effect, fails + env.As(t, env.Users.Contributor). + With(Context{Repo: env.Users.Limited.Repo}). + PostToRepoPage("/action/accept_transfer"). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Rejecting the transfer, however, succeeds. + env.As(t, env.Users.Contributor). + With(Context{Repo: env.Users.Limited.Repo}). + PostToRepoPage("/action/reject_transfer"). + ExpectStatus(http.StatusSeeOther) + }) + }) +} + +func TestGitQuotaEnforcement(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + // Lets create a little helper that runs a task for three of our repos: the + // user's repo, the limited org repo, and the unlimited org's. + // + // We expect the last one to always work, and the expected status of the + // other two is decided by the caller. + runTestForAllRepos := func(t *testing.T, task func(t *testing.T, repo *repo_model.Repository) error, expectSuccess bool) { + t.Helper() + + err := task(t, env.Users.Limited.Repo) + if expectSuccess { + require.NoError(t, err) + } else { + require.Error(t, err) + } + + err = task(t, env.Orgs.Limited.Repo) + if expectSuccess { + require.NoError(t, err) + } else { + require.Error(t, err) + } + + err = task(t, env.Orgs.Unlimited.Repo) + require.NoError(t, err) + } + + // Run tests with quotas disabled + runTestForAllReposWithQuotaDisabled := func(t *testing.T, task func(t *testing.T, repo *repo_model.Repository) error) { + t.Helper() + + t.Run("with quota disabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&setting.Quota.Enabled, false)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + runTestForAllRepos(t, task, true) + }) + } + + t.Run("push branch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Pushing a new branch is denied if the user is over quota. + runTestForAllRepos(t, func(t *testing.T, repo *repo_model.Repository) error { + return env.As(t, env.Users.Limited). + With(Context{Repo: repo}). + LocalClone(u). + Push("HEAD:new-branch") + }, false) + + // Pushing a new branch is always allowed if quota is disabled + runTestForAllReposWithQuotaDisabled(t, func(t *testing.T, repo *repo_model.Repository) error { + return env.As(t, env.Users.Limited). + With(Context{Repo: repo}). + LocalClone(u). + Push("HEAD:new-branch-wo-quota") + }) + }) + + t.Run("push tag", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Pushing a tag is denied if the user is over quota. + runTestForAllRepos(t, func(t *testing.T, repo *repo_model.Repository) error { + return env.As(t, env.Users.Limited). + With(Context{Repo: repo}). + LocalClone(u). + Tag("new-tag"). + Push("new-tag") + }, false) + + // ...but succeeds if the quota feature is disabled + runTestForAllReposWithQuotaDisabled(t, func(t *testing.T, repo *repo_model.Repository) error { + return env.As(t, env.Users.Limited). + With(Context{Repo: repo}). + LocalClone(u). + Tag("new-tag-wo-quota"). + Push("new-tag-wo-quota") + }) + }) + + t.Run("Agit PR", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Opening an Agit PR is *always* accepted. At least for now. + runTestForAllRepos(t, func(t *testing.T, repo *repo_model.Repository) error { + return env.As(t, env.Users.Limited). + With(Context{Repo: repo}). + LocalClone(u). + Push("HEAD:refs/for/main/agit-pr-branch") + }, true) + }) + + t.Run("delete branch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Deleting a branch is respected, and allowed. + err := env.As(t, env.Users.Limited). + WithoutQuota(func(ctx *quotaWebEnvAsContext) { + err := ctx. + LocalClone(u). + Push("HEAD:branch-to-delete") + require.NoError(ctx.t, err) + }). + Push(":branch-to-delete") + require.NoError(t, err) + }) + + t.Run("delete tag", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Deleting a tag is always allowed. + err := env.As(t, env.Users.Limited). + WithoutQuota(func(ctx *quotaWebEnvAsContext) { + err := ctx. + LocalClone(u). + Tag("tag-to-delete"). + Push("tag-to-delete") + require.NoError(ctx.t, err) + }). + Push(":tag-to-delete") + require.NoError(t, err) + }) + + t.Run("mixed push", func(t *testing.T) { + t.Run("all deletes", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Pushing multiple deletes is allowed. + err := env.As(t, env.Users.Limited). + WithoutQuota(func(ctx *quotaWebEnvAsContext) { + err := ctx. + LocalClone(u). + Tag("mixed-push-tag"). + Push("mixed-push-tag", "HEAD:mixed-push-branch") + require.NoError(ctx.t, err) + }). + Push(":mixed-push-tag", ":mixed-push-branch") + require.NoError(t, err) + }) + + t.Run("new & delete", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Pushing a mix of deletions & a new branch is rejected together. + err := env.As(t, env.Users.Limited). + WithoutQuota(func(ctx *quotaWebEnvAsContext) { + err := ctx. + LocalClone(u). + Tag("mixed-push-tag"). + Push("mixed-push-tag", "HEAD:mixed-push-branch") + require.NoError(ctx.t, err) + }). + Push(":mixed-push-tag", ":mixed-push-branch", "HEAD:mixed-push-branch-new") + require.Error(t, err) + + // ...unless quota is disabled + t.Run("with quota disabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&setting.Quota.Enabled, false)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + err := env.As(t, env.Users.Limited). + WithoutQuota(func(ctx *quotaWebEnvAsContext) { + err := ctx. + LocalClone(u). + Tag("mixed-push-tag-2"). + Push("mixed-push-tag-2", "HEAD:mixed-push-branch-2") + require.NoError(ctx.t, err) + }). + Push(":mixed-push-tag-2", ":mixed-push-branch-2", "HEAD:mixed-push-branch-new-2") + require.NoError(t, err) + }) + }) + }) + }) +} + +func TestQuotaConfigDefault(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + env := createQuotaWebEnv(t) + defer env.Cleanup() + + t.Run("with config-based default", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&setting.Quota.Default.Total, 0)() + + env.As(t, env.Users.Ungrouped). + With(Context{ + Payload: &Payload{ + "uid": env.Users.Ungrouped.ID().AsString(), + "repo_name": "quota-config-default", + }, + }). + PostToPage("/repo/create"). + ExpectStatus(http.StatusRequestEntityTooLarge) + }) + + t.Run("without config-based default", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + env.As(t, env.Users.Ungrouped). + With(Context{ + Payload: &Payload{ + "uid": env.Users.Ungrouped.ID().AsString(), + "repo_name": "quota-config-default", + }, + }). + PostToPage("/repo/create"). + ExpectStatus(http.StatusSeeOther) + }) + }) +} + +/********************** + * Here be dragons! * + * * + * . * + * .> )\;`a__ * + * ( _ _)/ /-." ~~ * + * `( )_ )/ * + * <_ <_ sb/dwb * + **********************/ + +type quotaWebEnv struct { + Users quotaWebEnvUsers + Orgs quotaWebEnvOrgs + + cleaners []func() +} + +type quotaWebEnvUsers struct { + Limited quotaWebEnvUser + Contributor quotaWebEnvUser + Ungrouped quotaWebEnvUser +} + +type quotaWebEnvOrgs struct { + Limited quotaWebEnvOrg + Unlimited quotaWebEnvOrg +} + +type quotaWebEnvOrg struct { + Org *org_model.Organization + + Repo *repo_model.Repository + + QuotaGroup *quota_model.Group + QuotaRule *quota_model.Rule +} + +type quotaWebEnvUser struct { + User *user_model.User + Session *TestSession + Repo *repo_model.Repository + + QuotaGroup *quota_model.Group + QuotaRule *quota_model.Rule +} + +type Payload map[string]string + +type quotaWebEnvAsContext struct { + t *testing.T + + Doer *quotaWebEnvUser + Repo *repo_model.Repository + + Payload Payload + + CSRFPath *string + + gitPath string + + request *RequestWrapper + response *httptest.ResponseRecorder +} + +type Context struct { + Repo *repo_model.Repository + Payload *Payload + CSRFPath *string +} + +func (ctx *quotaWebEnvAsContext) With(opts Context) *quotaWebEnvAsContext { + if opts.Repo != nil { + ctx.Repo = opts.Repo + } + if opts.Payload != nil { + for key, value := range *opts.Payload { + ctx.Payload[key] = value + } + } + if opts.CSRFPath != nil { + ctx.CSRFPath = opts.CSRFPath + } + return ctx +} + +func (ctx *quotaWebEnvAsContext) VisitPage(page string) *quotaWebEnvAsContext { + ctx.t.Helper() + + ctx.request = NewRequest(ctx.t, "GET", page) + + return ctx +} + +func (ctx *quotaWebEnvAsContext) VisitRepoPage(page string) *quotaWebEnvAsContext { + ctx.t.Helper() + + return ctx.VisitPage(ctx.Repo.Link() + page) +} + +func (ctx *quotaWebEnvAsContext) ExpectStatus(status int) *quotaWebEnvAsContext { + ctx.t.Helper() + + ctx.response = ctx.Doer.Session.MakeRequest(ctx.t, ctx.request, status) + + return ctx +} + +func (ctx *quotaWebEnvAsContext) ExpectFlashMessage(value string) { + ctx.t.Helper() + + htmlDoc := NewHTMLParser(ctx.t, ctx.response.Body) + flashMessage := strings.TrimSpace(htmlDoc.Find(`.flash-message`).Text()) + + assert.EqualValues(ctx.t, value, flashMessage) +} + +func (ctx *quotaWebEnvAsContext) ExpectFlashMessageContains(parts ...string) { + ctx.t.Helper() + + htmlDoc := NewHTMLParser(ctx.t, ctx.response.Body) + flashMessage := strings.TrimSpace(htmlDoc.Find(`.flash-message`).Text()) + + for _, part := range parts { + assert.Contains(ctx.t, flashMessage, part) + } +} + +func (ctx *quotaWebEnvAsContext) ExpectFlashCookieContains(parts ...string) { + ctx.t.Helper() + + flashCookie := ctx.Doer.Session.GetCookie(forgejo_context.CookieNameFlash) + assert.NotNil(ctx.t, flashCookie) + + // Need to decode the cookie twice + flashValue, err := url.QueryUnescape(flashCookie.Value) + require.NoError(ctx.t, err) + flashValue, err = url.QueryUnescape(flashValue) + require.NoError(ctx.t, err) + + for _, part := range parts { + assert.Contains(ctx.t, flashValue, part) + } +} + +func (ctx *quotaWebEnvAsContext) ForkRepoInto(org quotaWebEnvOrg) { + ctx.t.Helper() + + ctx. + With(Context{Payload: &Payload{ + "uid": org.ID().AsString(), + "repo_name": ctx.Repo.Name + "-fork", + }}). + PostToRepoPage("/fork"). + ExpectStatus(http.StatusSeeOther) +} + +func (ctx *quotaWebEnvAsContext) CreatePullFrom(org quotaWebEnvOrg) { + ctx.t.Helper() + + url := fmt.Sprintf("/compare/main...%s:main", org.Org.Name) + ctx. + With(Context{Payload: &Payload{ + "title": "PR test", + }}). + PostToRepoPage(url). + ExpectStatus(http.StatusOK) +} + +func (ctx *quotaWebEnvAsContext) PostToPage(page string) *quotaWebEnvAsContext { + ctx.t.Helper() + + payload := ctx.Payload + csrfPath := page + if ctx.CSRFPath != nil { + csrfPath = *ctx.CSRFPath + } + + payload["_csrf"] = GetCSRF(ctx.t, ctx.Doer.Session, csrfPath) + + ctx.request = NewRequestWithValues(ctx.t, "POST", page, payload) + + return ctx +} + +func (ctx *quotaWebEnvAsContext) PostToRepoPage(page string) *quotaWebEnvAsContext { + ctx.t.Helper() + + csrfPath := ctx.Repo.Link() + return ctx.With(Context{CSRFPath: &csrfPath}).PostToPage(ctx.Repo.Link() + page) +} + +func (ctx *quotaWebEnvAsContext) CreateAttachment(filename, attachmentType string) *quotaWebEnvAsContext { + ctx.t.Helper() + + body := &bytes.Buffer{} + image := generateImg() + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("file", filename) + require.NoError(ctx.t, err) + _, err = io.Copy(part, &image) + require.NoError(ctx.t, err) + err = writer.Close() + require.NoError(ctx.t, err) + + csrf := GetCSRF(ctx.t, ctx.Doer.Session, ctx.Repo.Link()) + + ctx.request = NewRequestWithBody(ctx.t, "POST", fmt.Sprintf("%s/%s/attachments", ctx.Repo.Link(), attachmentType), body) + ctx.request.Header.Add("X-Csrf-Token", csrf) + ctx.request.Header.Add("Content-Type", writer.FormDataContentType()) + + return ctx +} + +func (ctx *quotaWebEnvAsContext) CreateIssueAttachment(filename string) *quotaWebEnvAsContext { + ctx.t.Helper() + + return ctx.CreateAttachment(filename, "issues") +} + +func (ctx *quotaWebEnvAsContext) CreateReleaseAttachment(filename string) *quotaWebEnvAsContext { + ctx.t.Helper() + + return ctx.CreateAttachment(filename, "releases") +} + +func (ctx *quotaWebEnvAsContext) WithoutQuota(task func(ctx *quotaWebEnvAsContext)) *quotaWebEnvAsContext { + ctx.t.Helper() + + defer ctx.Doer.SetQuota(-1)() + task(ctx) + + return ctx +} + +func (ctx *quotaWebEnvAsContext) CreateMirror() *repo_model.Repository { + ctx.t.Helper() + + doer := ctx.Doer.User + + repo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, doer, doer, repo_service.CreateRepoOptions{ + Name: "test-mirror", + IsMirror: true, + Status: repo_model.RepositoryBeingMigrated, + }) + require.NoError(ctx.t, err) + + return repo +} + +func (ctx *quotaWebEnvAsContext) LocalClone(u *url.URL) *quotaWebEnvAsContext { + ctx.t.Helper() + + gitPath := ctx.t.TempDir() + + doGitInitTestRepository(gitPath, git.Sha1ObjectFormat)(ctx.t) + + oldPath := u.Path + oldUser := u.User + defer func() { + u.Path = oldPath + u.User = oldUser + }() + u.Path = ctx.Repo.FullName() + ".git" + u.User = url.UserPassword(ctx.Doer.User.LowerName, userPassword) + + doGitAddRemote(gitPath, "origin", u)(ctx.t) + + ctx.gitPath = gitPath + + return ctx +} + +func (ctx *quotaWebEnvAsContext) Push(params ...string) error { + ctx.t.Helper() + + gitRepo, err := git.OpenRepository(git.DefaultContext, ctx.gitPath) + require.NoError(ctx.t, err) + defer gitRepo.Close() + + _, _, err = git.NewCommand(git.DefaultContext, "push", "origin"). + AddArguments(git.ToTrustedCmdArgs(params)...). + RunStdString(&git.RunOpts{Dir: ctx.gitPath}) + + return err +} + +func (ctx *quotaWebEnvAsContext) Tag(tagName string) *quotaWebEnvAsContext { + ctx.t.Helper() + + gitRepo, err := git.OpenRepository(git.DefaultContext, ctx.gitPath) + require.NoError(ctx.t, err) + defer gitRepo.Close() + + _, _, err = git.NewCommand(git.DefaultContext, "tag"). + AddArguments(git.ToTrustedCmdArgs([]string{tagName})...). + RunStdString(&git.RunOpts{Dir: ctx.gitPath}) + require.NoError(ctx.t, err) + + return ctx +} + +func (user *quotaWebEnvUser) SetQuota(limit int64) func() { + previousLimit := user.QuotaRule.Limit + + user.QuotaRule.Limit = limit + user.QuotaRule.Edit(db.DefaultContext, &limit, nil) + + return func() { + user.QuotaRule.Limit = previousLimit + user.QuotaRule.Edit(db.DefaultContext, &previousLimit, nil) + } +} + +func (user *quotaWebEnvUser) ID() convertAs { + return convertAs{ + asString: fmt.Sprintf("%d", user.User.ID), + } +} + +func (org *quotaWebEnvOrg) ID() convertAs { + return convertAs{ + asString: fmt.Sprintf("%d", org.Org.ID), + } +} + +type convertAs struct { + asString string +} + +func (cas convertAs) AsString() string { + return cas.asString +} + +func (env *quotaWebEnv) Cleanup() { + for i := len(env.cleaners) - 1; i >= 0; i-- { + env.cleaners[i]() + } +} + +func (env *quotaWebEnv) As(t *testing.T, user quotaWebEnvUser) *quotaWebEnvAsContext { + t.Helper() + + ctx := quotaWebEnvAsContext{ + t: t, + Doer: &user, + Repo: user.Repo, + + Payload: Payload{}, + } + return &ctx +} + +func (env *quotaWebEnv) RunVisitAndPostToRepoPageTests(t *testing.T, page string, payload *Payload, successStatus int) { + t.Helper() + + // Visiting the user's repo page fails due to being over quota. + env.As(t, env.Users.Limited). + With(Context{Repo: env.Users.Limited.Repo}). + VisitRepoPage(page). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Posting as the limited user, to the limited repo, fails due to being over + // quota. + csrfPath := env.Users.Limited.Repo.Link() + env.As(t, env.Users.Limited). + With(Context{ + Payload: payload, + CSRFPath: &csrfPath, + Repo: env.Users.Limited.Repo, + }). + PostToRepoPage(page). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Visiting the limited org's repo page fails due to being over quota. + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Limited.Repo}). + VisitRepoPage(page). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Posting as the limited user, to a limited org's repo, fails for the same + // reason. + csrfPath = env.Orgs.Limited.Repo.Link() + env.As(t, env.Users.Limited). + With(Context{ + Payload: payload, + CSRFPath: &csrfPath, + Repo: env.Orgs.Limited.Repo, + }). + PostToRepoPage(page). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Visiting the repo page for the unlimited org succeeds. + env.As(t, env.Users.Limited). + With(Context{Repo: env.Orgs.Unlimited.Repo}). + VisitRepoPage(page). + ExpectStatus(http.StatusOK) + + // Posting as the limited user, to an unlimited org's repo, succeeds. + csrfPath = env.Orgs.Unlimited.Repo.Link() + env.As(t, env.Users.Limited). + With(Context{ + Payload: payload, + CSRFPath: &csrfPath, + Repo: env.Orgs.Unlimited.Repo, + }). + PostToRepoPage(page). + ExpectStatus(successStatus) +} + +func (env *quotaWebEnv) RunVisitAndPostToPageTests(t *testing.T, page string, payload *Payload, successStatus int) { + t.Helper() + + // Visiting the page is always fine. + env.As(t, env.Users.Limited). + VisitPage(page). + ExpectStatus(http.StatusOK) + + // Posting as the Limited user fails, because it is over quota. + env.As(t, env.Users.Limited). + With(Context{Payload: payload}). + With(Context{ + Payload: &Payload{ + "uid": env.Users.Limited.ID().AsString(), + }, + }). + PostToPage(page). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Posting to a limited org also fails, for the same reason. + env.As(t, env.Users.Limited). + With(Context{Payload: payload}). + With(Context{ + Payload: &Payload{ + "uid": env.Orgs.Limited.ID().AsString(), + }, + }). + PostToPage(page). + ExpectStatus(http.StatusRequestEntityTooLarge) + + // Posting to an unlimited repo works, however. + env.As(t, env.Users.Limited). + With(Context{Payload: payload}). + With(Context{ + Payload: &Payload{ + "uid": env.Orgs.Unlimited.ID().AsString(), + }, + }). + PostToPage(page). + ExpectStatus(successStatus) +} + +func createQuotaWebEnv(t *testing.T) *quotaWebEnv { + t.Helper() + + // *** helpers *** + + makeUngroupedUser := func(t *testing.T) quotaWebEnvUser { + t.Helper() + + user := quotaWebEnvUser{} + + // Create the user + userName := gouuid.NewString() + apiCreateUser(t, userName) + user.User = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: userName}) + user.Session = loginUser(t, userName) + + // Create a repository for the user + repo, _, _ := tests.CreateDeclarativeRepoWithOptions(t, user.User, tests.DeclarativeRepoOptions{}) + user.Repo = repo + + return user + } + + // Create a user, its quota group & rule + makeUser := func(t *testing.T, limit int64) quotaWebEnvUser { + t.Helper() + + user := makeUngroupedUser(t) + userName := user.User.Name + + // Create a quota group for them + group, err := quota_model.CreateGroup(db.DefaultContext, userName) + require.NoError(t, err) + user.QuotaGroup = group + + // Create a rule + rule, err := quota_model.CreateRule(db.DefaultContext, userName, limit, quota_model.LimitSubjects{quota_model.LimitSubjectSizeAll}) + require.NoError(t, err) + user.QuotaRule = rule + + // Add the rule to the group + err = group.AddRuleByName(db.DefaultContext, rule.Name) + require.NoError(t, err) + + // Add the user to the group + err = group.AddUserByID(db.DefaultContext, user.User.ID) + require.NoError(t, err) + + return user + } + + // Create a user, its quota group & rule + makeOrg := func(t *testing.T, owner *user_model.User, limit int64) quotaWebEnvOrg { + t.Helper() + + org := quotaWebEnvOrg{} + + // Create the org + userName := gouuid.NewString() + org.Org = &org_model.Organization{ + Name: userName, + } + err := org_model.CreateOrganization(db.DefaultContext, org.Org, owner) + require.NoError(t, err) + + // Create a repository for the org + orgUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: org.Org.ID}) + repo, _, _ := tests.CreateDeclarativeRepoWithOptions(t, orgUser, tests.DeclarativeRepoOptions{}) + org.Repo = repo + + // Create a quota group for them + group, err := quota_model.CreateGroup(db.DefaultContext, userName) + require.NoError(t, err) + org.QuotaGroup = group + + // Create a rule + rule, err := quota_model.CreateRule(db.DefaultContext, userName, limit, quota_model.LimitSubjects{quota_model.LimitSubjectSizeAll}) + require.NoError(t, err) + org.QuotaRule = rule + + // Add the rule to the group + err = group.AddRuleByName(db.DefaultContext, rule.Name) + require.NoError(t, err) + + // Add the org to the group + err = group.AddUserByID(db.DefaultContext, org.Org.ID) + require.NoError(t, err) + + return org + } + + env := quotaWebEnv{} + env.cleaners = []func(){ + test.MockVariableValue(&setting.Quota.Enabled, true), + test.MockVariableValue(&testWebRoutes, routers.NormalRoutes()), + } + + // Create the limited user and the various orgs, and a contributor who's not + // in any of the orgs. + env.Users.Limited = makeUser(t, int64(0)) + env.Users.Contributor = makeUser(t, int64(0)) + env.Orgs.Limited = makeOrg(t, env.Users.Limited.User, int64(0)) + env.Orgs.Unlimited = makeOrg(t, env.Users.Limited.User, int64(-1)) + + env.Users.Ungrouped = makeUngroupedUser(t) + + return &env +} diff --git a/tests/integration/release_test.go b/tests/integration/release_test.go new file mode 100644 index 0000000..48c2b37 --- /dev/null +++ b/tests/integration/release_test.go @@ -0,0 +1,353 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "strconv" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createNewRelease(t *testing.T, session *TestSession, repoURL, tag, title string, preRelease, draft bool) { + createNewReleaseTarget(t, session, repoURL, tag, title, "master", preRelease, draft) +} + +func createNewReleaseTarget(t *testing.T, session *TestSession, repoURL, tag, title, target string, preRelease, draft bool) { + req := NewRequest(t, "GET", repoURL+"/releases/new") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action") + assert.True(t, exists, "The template has changed") + + postData := map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "tag_name": tag, + "tag_target": target, + "title": title, + "content": "", + } + if preRelease { + postData["prerelease"] = "on" + } + if draft { + postData["draft"] = "Save Draft" + } + req = NewRequestWithValues(t, "POST", link, postData) + + resp = session.MakeRequest(t, req, http.StatusSeeOther) + + test.RedirectURL(resp) // check that redirect URL exists +} + +func checkLatestReleaseAndCount(t *testing.T, session *TestSession, repoURL, version, label string, count int) { + req := NewRequest(t, "GET", repoURL+"/releases") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + labelText := htmlDoc.doc.Find("#release-list > li .detail .label").First().Text() + assert.EqualValues(t, label, labelText) + titleText := htmlDoc.doc.Find("#release-list > li .detail h4 a").First().Text() + assert.EqualValues(t, version, titleText) + + // Check release count in the counter on the Release/Tag switch, as well as that the tab is highlighted + if count < 10 { // Only check values less than 10, should be enough attempts before this test cracks + // 10 is the pagination limit, but the counter can have more than that + releaseTab := htmlDoc.doc.Find(".repository.releases .ui.compact.menu a.active.item[href$='/releases']") + assert.Contains(t, releaseTab.Text(), strconv.Itoa(count)+" release") // Could be "1 release" or "4 releases" + } + + releaseList := htmlDoc.doc.Find("#release-list > li") + assert.EqualValues(t, count, releaseList.Length()) +} + +func TestViewReleases(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user2/repo1/releases") + session.MakeRequest(t, req, http.StatusOK) + + // if CI is too slow this test fail, so lets wait a bit + time.Sleep(time.Millisecond * 100) +} + +func TestViewReleasesNoLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/user2/repo1/releases") + MakeRequest(t, req, http.StatusOK) +} + +func TestCreateRelease(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + createNewRelease(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", false, false) + + checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", translation.NewLocale("en-US").TrString("repo.release.stable"), 4) +} + +func TestDeleteRelease(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 57, OwnerName: "user2", LowerName: "repo-release"}) + release := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{TagName: "v2.0"}) + assert.False(t, release.IsTag) + + // Using the ID of a comment that does not belong to the repository must fail + session5 := loginUser(t, "user5") + otherRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user5", LowerName: "repo4"}) + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/releases/delete?id=%d", otherRepo.Link(), release.ID), map[string]string{ + "_csrf": GetCSRF(t, session5, otherRepo.Link()), + }) + session5.MakeRequest(t, req, http.StatusNotFound) + + session := loginUser(t, "user2") + req = NewRequestWithValues(t, "POST", fmt.Sprintf("%s/releases/delete?id=%d", repo.Link(), release.ID), map[string]string{ + "_csrf": GetCSRF(t, session, repo.Link()), + }) + session.MakeRequest(t, req, http.StatusOK) + release = unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: release.ID}) + + if assert.True(t, release.IsTag) { + req = NewRequestWithValues(t, "POST", fmt.Sprintf("%s/tags/delete?id=%d", otherRepo.Link(), release.ID), map[string]string{ + "_csrf": GetCSRF(t, session5, otherRepo.Link()), + }) + session5.MakeRequest(t, req, http.StatusNotFound) + + req = NewRequestWithValues(t, "POST", fmt.Sprintf("%s/tags/delete?id=%d", repo.Link(), release.ID), map[string]string{ + "_csrf": GetCSRF(t, session, repo.Link()), + }) + session.MakeRequest(t, req, http.StatusOK) + + unittest.AssertNotExistsBean(t, &repo_model.Release{ID: release.ID}) + } +} + +func TestCreateReleasePreRelease(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + createNewRelease(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", true, false) + + checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", translation.NewLocale("en-US").TrString("repo.release.prerelease"), 4) +} + +func TestCreateReleaseDraft(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + createNewRelease(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", false, true) + + checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", translation.NewLocale("en-US").TrString("repo.release.draft"), 4) +} + +func TestCreateReleasePaging(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + oldAPIDefaultNum := setting.API.DefaultPagingNum + defer func() { + setting.API.DefaultPagingNum = oldAPIDefaultNum + }() + setting.API.DefaultPagingNum = 10 + + session := loginUser(t, "user2") + // Create enough releases to have paging + for i := 0; i < 12; i++ { + version := fmt.Sprintf("v0.0.%d", i) + createNewRelease(t, session, "/user2/repo1", version, version, false, false) + } + createNewRelease(t, session, "/user2/repo1", "v0.0.12", "v0.0.12", false, true) + + checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.12", translation.NewLocale("en-US").TrString("repo.release.draft"), 10) + + // Check that user4 does not see draft and still see 10 latest releases + session2 := loginUser(t, "user4") + checkLatestReleaseAndCount(t, session2, "/user2/repo1", "v0.0.11", translation.NewLocale("en-US").TrString("repo.release.stable"), 10) +} + +func TestViewReleaseListNoLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 57, OwnerName: "user2", LowerName: "repo-release"}) + + link := repo.Link() + "/releases" + + req := NewRequest(t, "GET", link) + rsp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, rsp.Body) + releases := htmlDoc.Find("#release-list li.ui.grid") + assert.Equal(t, 5, releases.Length()) + + links := make([]string, 0, 5) + commitsToMain := make([]string, 0, 5) + releases.Each(func(i int, s *goquery.Selection) { + link, exist := s.Find(".release-list-title a").Attr("href") + if !exist { + return + } + links = append(links, link) + + commitsToMain = append(commitsToMain, s.Find(".ahead > a").Text()) + }) + + assert.EqualValues(t, []string{ + "/user2/repo-release/releases/tag/empty-target-branch", + "/user2/repo-release/releases/tag/non-existing-target-branch", + "/user2/repo-release/releases/tag/v2.0", + "/user2/repo-release/releases/tag/v1.1", + "/user2/repo-release/releases/tag/v1.0", + }, links) + assert.EqualValues(t, []string{ + "1 commits", // like v1.1 + "1 commits", // like v1.1 + "0 commits", + "1 commits", // should be 3 commits ahead and 2 commits behind, but not implemented yet + "3 commits", + }, commitsToMain) +} + +func TestViewSingleReleaseNoLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/user2/repo-release/releases/tag/v1.0") + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + // check the "number of commits to main since this release" + releaseList := htmlDoc.doc.Find("#release-list .ahead > a") + assert.EqualValues(t, 1, releaseList.Length()) + assert.EqualValues(t, "3 commits", releaseList.First().Text()) +} + +func TestViewReleaseListLogin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + link := repo.Link() + "/releases" + + session := loginUser(t, "user1") + req := NewRequest(t, "GET", link) + rsp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, rsp.Body) + releases := htmlDoc.Find("#release-list li.ui.grid") + assert.Equal(t, 3, releases.Length()) + + links := make([]string, 0, 5) + releases.Each(func(i int, s *goquery.Selection) { + link, exist := s.Find(".release-list-title a").Attr("href") + if !exist { + return + } + links = append(links, link) + }) + + assert.EqualValues(t, []string{ + "/user2/repo1/releases/tag/draft-release", + "/user2/repo1/releases/tag/v1.0", + "/user2/repo1/releases/tag/v1.1", + }, links) +} + +func TestReleaseOnCommit(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + createNewReleaseTarget(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", "65f1bf27bc3bf70f64657658635e66094edbcb4d", false, false) + + checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", translation.NewLocale("en-US").TrString("repo.release.stable"), 4) +} + +func TestViewTagsList(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + link := repo.Link() + "/tags" + + session := loginUser(t, "user1") + req := NewRequest(t, "GET", link) + rsp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, rsp.Body) + tags := htmlDoc.Find(".tag-list tr") + assert.Equal(t, 3, tags.Length()) + + tagNames := make([]string, 0, 5) + tags.Each(func(i int, s *goquery.Selection) { + tagNames = append(tagNames, s.Find(".tag a.tw-flex.tw-items-center").Text()) + }) + + assert.EqualValues(t, []string{"v1.0", "delete-tag", "v1.1"}, tagNames) +} + +func TestDownloadReleaseAttachment(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + tests.PrepareAttachmentsStorage(t) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + + url := repo.Link() + "/releases/download/v1.1/README.md" + + req := NewRequest(t, "GET", url) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", url) + session := loginUser(t, "user2") + session.MakeRequest(t, req, http.StatusOK) +} + +func TestReleaseHideArchiveLinksUI(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + release := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{TagName: "v2.0"}) + + require.NoError(t, release.LoadAttributes(db.DefaultContext)) + + session := loginUser(t, release.Repo.OwnerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + zipURL := fmt.Sprintf("%s/archive/%s.zip", release.Repo.Link(), release.TagName) + tarGzURL := fmt.Sprintf("%s/archive/%s.tar.gz", release.Repo.Link(), release.TagName) + + resp := session.MakeRequest(t, NewRequest(t, "GET", release.HTMLURL()), http.StatusOK) + body := resp.Body.String() + assert.Contains(t, body, zipURL) + assert.Contains(t, body, tarGzURL) + + hideArchiveLinks := true + + req := NewRequestWithJSON(t, "PATCH", release.APIURL(), &api.EditReleaseOption{ + HideArchiveLinks: &hideArchiveLinks, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + resp = session.MakeRequest(t, NewRequest(t, "GET", release.HTMLURL()), http.StatusOK) + body = resp.Body.String() + assert.NotContains(t, body, zipURL) + assert.NotContains(t, body, tarGzURL) +} diff --git a/tests/integration/remote_test.go b/tests/integration/remote_test.go new file mode 100644 index 0000000..c59b4c7 --- /dev/null +++ b/tests/integration/remote_test.go @@ -0,0 +1,206 @@ +// Copyright Earl Warren <contact@earl-warren.org> +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/test" + remote_service "code.gitea.io/gitea/services/remote" + "code.gitea.io/gitea/tests" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRemote_MaybePromoteUserSuccess(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // + // OAuth2 authentication source GitLab + // + gitlabName := "gitlab" + _ = addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName)) + // + // Remote authentication source matching the GitLab authentication source + // + remoteName := "remote" + remote := createRemoteAuthSource(t, remoteName, "http://mygitlab.eu", gitlabName) + + // + // Create a user as if it had previously been created by the remote + // authentication source. + // + gitlabUserID := "5678" + gitlabEmail := "gitlabuser@example.com" + userBeforeSignIn := &user_model.User{ + Name: "gitlabuser", + Type: user_model.UserTypeRemoteUser, + LoginType: auth_model.Remote, + LoginSource: remote.ID, + LoginName: gitlabUserID, + } + defer createUser(context.Background(), t, userBeforeSignIn)() + + // + // A request for user information sent to Goth will return a + // goth.User exactly matching the user created above. + // + defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) { + return goth.User{ + Provider: gitlabName, + UserID: gitlabUserID, + Email: gitlabEmail, + }, nil + })() + req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName)) + resp := MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/", test.RedirectURL(resp)) + userAfterSignIn := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userBeforeSignIn.ID}) + + // both are about the same user + assert.Equal(t, userBeforeSignIn.ID, userAfterSignIn.ID) + // the login time was updated, proof the login succeeded + assert.Greater(t, userAfterSignIn.LastLoginUnix, userBeforeSignIn.LastLoginUnix) + // the login type was promoted from Remote to OAuth2 + assert.Equal(t, auth_model.Remote, userBeforeSignIn.LoginType) + assert.Equal(t, auth_model.OAuth2, userAfterSignIn.LoginType) + // the OAuth2 email was used to set the missing user email + assert.Equal(t, "", userBeforeSignIn.Email) + assert.Equal(t, gitlabEmail, userAfterSignIn.Email) +} + +func TestRemote_MaybePromoteUserFail(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + ctx := context.Background() + // + // OAuth2 authentication source GitLab + // + gitlabName := "gitlab" + gitlabSource := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName)) + // + // Remote authentication source matching the GitLab authentication source + // + remoteName := "remote" + remoteSource := createRemoteAuthSource(t, remoteName, "http://mygitlab.eu", gitlabName) + + { + promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, &auth_model.Source{}, "", "") + require.NoError(t, err) + assert.False(t, promoted) + assert.Equal(t, remote_service.ReasonNotAuth2, reason) + } + + { + remoteSource.Type = auth_model.OAuth2 + promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, remoteSource, "", "") + require.NoError(t, err) + assert.False(t, promoted) + assert.Equal(t, remote_service.ReasonBadAuth2, reason) + remoteSource.Type = auth_model.Remote + } + + { + promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, "unknownloginname", "") + require.NoError(t, err) + assert.False(t, promoted) + assert.Equal(t, remote_service.ReasonLoginNameNotExists, reason) + } + + { + remoteUserID := "844" + remoteUser := &user_model.User{ + Name: "withmailuser", + Type: user_model.UserTypeRemoteUser, + LoginType: auth_model.Remote, + LoginSource: remoteSource.ID, + LoginName: remoteUserID, + Email: "some@example.com", + } + defer createUser(context.Background(), t, remoteUser)() + promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, remoteUserID, "") + require.NoError(t, err) + assert.False(t, promoted) + assert.Equal(t, remote_service.ReasonEmailIsSet, reason) + } + + { + remoteUserID := "7464" + nonexistentloginsource := int64(4344) + remoteUser := &user_model.User{ + Name: "badsourceuser", + Type: user_model.UserTypeRemoteUser, + LoginType: auth_model.Remote, + LoginSource: nonexistentloginsource, + LoginName: remoteUserID, + } + defer createUser(context.Background(), t, remoteUser)() + promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, remoteUserID, "") + require.NoError(t, err) + assert.False(t, promoted) + assert.Equal(t, remote_service.ReasonNoSource, reason) + } + + { + remoteUserID := "33335678" + remoteUser := &user_model.User{ + Name: "badremoteuser", + Type: user_model.UserTypeRemoteUser, + LoginType: auth_model.Remote, + LoginSource: gitlabSource.ID, + LoginName: remoteUserID, + } + defer createUser(context.Background(), t, remoteUser)() + promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, remoteUserID, "") + require.NoError(t, err) + assert.False(t, promoted) + assert.Equal(t, remote_service.ReasonSourceWrongType, reason) + } + + { + unrelatedName := "unrelated" + unrelatedSource := addAuthSource(t, authSourcePayloadGitHubCustom(unrelatedName)) + assert.NotNil(t, unrelatedSource) + + remoteUserID := "488484" + remoteEmail := "4848484@example.com" + remoteUser := &user_model.User{ + Name: "unrelateduser", + Type: user_model.UserTypeRemoteUser, + LoginType: auth_model.Remote, + LoginSource: remoteSource.ID, + LoginName: remoteUserID, + } + defer createUser(context.Background(), t, remoteUser)() + promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, unrelatedSource, remoteUserID, remoteEmail) + require.NoError(t, err) + assert.False(t, promoted) + assert.Equal(t, remote_service.ReasonNoMatch, reason) + } + + { + remoteUserID := "5678" + remoteEmail := "gitlabuser@example.com" + remoteUser := &user_model.User{ + Name: "remoteuser", + Type: user_model.UserTypeRemoteUser, + LoginType: auth_model.Remote, + LoginSource: remoteSource.ID, + LoginName: remoteUserID, + } + defer createUser(context.Background(), t, remoteUser)() + promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, remoteUserID, remoteEmail) + require.NoError(t, err) + assert.True(t, promoted) + assert.Equal(t, remote_service.ReasonPromoted, reason) + } +} diff --git a/tests/integration/rename_branch_test.go b/tests/integration/rename_branch_test.go new file mode 100644 index 0000000..a96cbf5 --- /dev/null +++ b/tests/integration/rename_branch_test.go @@ -0,0 +1,176 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "testing" + + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + gitea_context "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestRenameBranch(t *testing.T) { + onGiteaRun(t, testRenameBranch) +} + +func testRenameBranch(t *testing.T, u *url.URL) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: "master"}) + + // get branch setting page + session := loginUser(t, "user2") + t.Run("Normal", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/rename_branch", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1/settings/branches"), + "from": "master", + "to": "main", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + // check new branch link + req = NewRequest(t, "GET", "/user2/repo1/src/branch/main/README.md") + session.MakeRequest(t, req, http.StatusOK) + + // check old branch link + req = NewRequest(t, "GET", "/user2/repo1/src/branch/master/README.md") + resp := session.MakeRequest(t, req, http.StatusSeeOther) + location := resp.Header().Get("Location") + assert.Equal(t, "/user2/repo1/src/branch/main/README.md", location) + + // check db + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + assert.Equal(t, "main", repo1.DefaultBranch) + }) + + t.Run("Database synchronization", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/rename_branch", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1/settings/branches"), + "from": "master", + "to": "main", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + // check new branch link + req = NewRequestWithValues(t, "GET", "/user2/repo1/src/branch/main/README.md", nil) + session.MakeRequest(t, req, http.StatusOK) + + // check old branch link + req = NewRequestWithValues(t, "GET", "/user2/repo1/src/branch/master/README.md", nil) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + location := resp.Header().Get("Location") + assert.Equal(t, "/user2/repo1/src/branch/main/README.md", location) + + // check db + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + assert.Equal(t, "main", repo1.DefaultBranch) + + // create branch1 + csrf := GetCSRF(t, session, "/user2/repo1/src/branch/main") + + req = NewRequestWithValues(t, "POST", "/user2/repo1/branches/_new/branch/main", map[string]string{ + "_csrf": csrf, + "new_branch_name": "branch1", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + branch1 := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "branch1"}) + assert.Equal(t, "branch1", branch1.Name) + + // create branch2 + req = NewRequestWithValues(t, "POST", "/user2/repo1/branches/_new/branch/main", map[string]string{ + "_csrf": csrf, + "new_branch_name": "branch2", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + branch2 := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "branch2"}) + assert.Equal(t, "branch2", branch2.Name) + + // rename branch2 to branch1 + req = NewRequestWithValues(t, "POST", "/user2/repo1/settings/rename_branch", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1/settings/branches"), + "from": "branch2", + "to": "branch1", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + flashCookie := session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.Contains(t, flashCookie.Value, "error") + + branch2 = unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "branch2"}) + assert.Equal(t, "branch2", branch2.Name) + branch1 = unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "branch1"}) + assert.Equal(t, "branch1", branch1.Name) + + // delete branch1 + req = NewRequestWithValues(t, "POST", "/user2/repo1/branches/delete", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1/settings/branches"), + "name": "branch1", + }) + session.MakeRequest(t, req, http.StatusOK) + branch2 = unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "branch2"}) + assert.Equal(t, "branch2", branch2.Name) + branch1 = unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "branch1"}) + assert.True(t, branch1.IsDeleted) // virtual deletion + + // rename branch2 to branch1 again + req = NewRequestWithValues(t, "POST", "/user2/repo1/settings/rename_branch", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1/settings/branches"), + "from": "branch2", + "to": "branch1", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie = session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.Contains(t, flashCookie.Value, "success") + + unittest.AssertNotExistsBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "branch2"}) + branch1 = unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "branch1"}) + assert.Equal(t, "branch1", branch1.Name) + }) + + t.Run("Protected branch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Add protected branch + req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1/settings/branches/edit"), + "rule_name": "*", + "enable_push": "true", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + // Verify it was added. + unittest.AssertExistsIf(t, true, &git_model.ProtectedBranch{RuleName: "*", RepoID: repo.ID}) + + req = NewRequestWithValues(t, "POST", "/user2/repo1/settings/rename_branch", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1/settings/branches"), + "from": "main", + "to": "main2", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "error%3DCannot%2Brename%2Bbranch%2Bmain2%2Bbecause%2Bit%2Bis%2Ba%2Bprotected%2Bbranch.", flashCookie.Value) + + // Verify it didn't change. + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + assert.Equal(t, "main", repo1.DefaultBranch) + }) +} diff --git a/tests/integration/repo_activity_test.go b/tests/integration/repo_activity_test.go new file mode 100644 index 0000000..c1177fa --- /dev/null +++ b/tests/integration/repo_activity_test.go @@ -0,0 +1,216 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/test" + repo_service "code.gitea.io/gitea/services/repository" + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepoActivity(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + session := loginUser(t, "user1") + + // Create PRs (1 merged & 2 proposed) + testRepoFork(t, session, "user2", "repo1", "user1", "repo1") + testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n") + resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "This is a pull title") + elem := strings.Split(test.RedirectURL(resp), "/") + assert.EqualValues(t, "pulls", elem[3]) + testPullMerge(t, session, elem[1], elem[2], elem[4], repo_model.MergeStyleMerge, false) + + testEditFileToNewBranch(t, session, "user1", "repo1", "master", "feat/better_readme", "README.md", "Hello, World (Edited Again)\n") + testPullCreate(t, session, "user1", "repo1", false, "master", "feat/better_readme", "This is a pull title") + + testEditFileToNewBranch(t, session, "user1", "repo1", "master", "feat/much_better_readme", "README.md", "Hello, World (Edited More)\n") + testPullCreate(t, session, "user1", "repo1", false, "master", "feat/much_better_readme", "This is a pull title") + + // Create issues (3 new issues) + testNewIssue(t, session, "user2", "repo1", "Issue 1", "Description 1") + testNewIssue(t, session, "user2", "repo1", "Issue 2", "Description 2") + testNewIssue(t, session, "user2", "repo1", "Issue 3", "Description 3") + + // Create releases (1 release, 1 pre-release, 1 release-draft, 1 tag) + createNewRelease(t, session, "/user2/repo1", "v1.0.0", "v1 Release", false, false) + createNewRelease(t, session, "/user2/repo1", "v0.1.0", "v0.1 Pre-release", true, false) + createNewRelease(t, session, "/user2/repo1", "v2.0.0", "v2 Release-Draft", false, true) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + createNewTagUsingAPI(t, token, "user2", "repo1", "v3.0.0", "master", "Tag message") + + // Open Activity page and check stats + req := NewRequest(t, "GET", "/user2/repo1/activity") + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + // Should be 3 published releases + list := htmlDoc.doc.Find("#published-releases").Next().Find("p.desc") + assert.Len(t, list.Nodes, 3) + var labels []string + var titles []string + list.Each(func(i int, s *goquery.Selection) { + labels = append(labels, s.Find(".label").Text()) + titles = append(titles, s.Find(".title").Text()) + }) + sort.Strings(labels) + sort.Strings(titles) + assert.Equal(t, []string{"Pre-release", "Release", "Tag"}, labels) + assert.Equal(t, []string{"", "v0.1 Pre-release", "v1 Release"}, titles) + + // Should be 1 merged pull request + list = htmlDoc.doc.Find("#merged-pull-requests").Next().Find("p.desc") + assert.Len(t, list.Nodes, 1) + assert.Equal(t, "Merged", list.Find(".label").Text()) + + // Should be 2 proposed pull requests + list = htmlDoc.doc.Find("#proposed-pull-requests").Next().Find("p.desc") + assert.Len(t, list.Nodes, 2) + assert.Equal(t, "Proposed", list.Find(".label").First().Text()) + + // Should be 0 closed issues + list = htmlDoc.doc.Find("#closed-issues").Next().Find("p.desc") + assert.Empty(t, list.Nodes) + + // Should be 3 new issues + list = htmlDoc.doc.Find("#new-issues").Next().Find("p.desc") + assert.Len(t, list.Nodes, 3) + assert.Equal(t, "Opened", list.Find(".label").First().Text()) + }) +} + +func TestRepoActivityAllUnitsDisabled(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"}) + session := loginUser(t, user.Name) + + unit_model.LoadUnitConfig() + + // Create a repo, with no unit enabled. + repo, err := repo_service.CreateRepository(db.DefaultContext, user, user, repo_service.CreateRepoOptions{ + Name: "empty-repo", + AutoInit: false, + }) + require.NoError(t, err) + assert.NotEmpty(t, repo) + + enabledUnits := make([]repo_model.RepoUnit, 0) + disabledUnits := []unit_model.Type{unit_model.TypeCode, unit_model.TypeIssues, unit_model.TypePullRequests, unit_model.TypeReleases} + err = repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, enabledUnits, disabledUnits) + require.NoError(t, err) + + req := NewRequest(t, "GET", fmt.Sprintf("%s/activity", repo.Link())) + session.MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/contributors", repo.Link())) + session.MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/code-frequency", repo.Link())) + session.MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/recent-commits", repo.Link())) + session.MakeRequest(t, req, http.StatusNotFound) +} + +func TestRepoActivityOnlyCodeUnitWithEmptyRepo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"}) + session := loginUser(t, user.Name) + + unit_model.LoadUnitConfig() + + // Create a empty repo, with only code unit enabled. + repo, err := repo_service.CreateRepository(db.DefaultContext, user, user, repo_service.CreateRepoOptions{ + Name: "empty-repo", + AutoInit: false, + }) + require.NoError(t, err) + assert.NotEmpty(t, repo) + + enabledUnits := make([]repo_model.RepoUnit, 1) + enabledUnits[0] = repo_model.RepoUnit{RepoID: repo.ID, Type: unit_model.TypeCode} + disabledUnits := []unit_model.Type{unit_model.TypeIssues, unit_model.TypePullRequests, unit_model.TypeReleases} + err = repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, enabledUnits, disabledUnits) + require.NoError(t, err) + + req := NewRequest(t, "GET", fmt.Sprintf("%s/activity", repo.Link())) + session.MakeRequest(t, req, http.StatusOK) + + // Git repo empty so no activity for contributors etc + req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/contributors", repo.Link())) + session.MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/code-frequency", repo.Link())) + session.MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/recent-commits", repo.Link())) + session.MakeRequest(t, req, http.StatusNotFound) +} + +func TestRepoActivityOnlyCodeUnitWithNonEmptyRepo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"}) + session := loginUser(t, user.Name) + + unit_model.LoadUnitConfig() + + // Create a repo, with only code unit enabled. + repo, _, f := tests.CreateDeclarativeRepo(t, user, "", []unit_model.Type{unit_model.TypeCode}, nil, nil) + defer f() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/activity", repo.Link())) + session.MakeRequest(t, req, http.StatusOK) + + // Git repo not empty so activity for contributors etc + req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/contributors", repo.Link())) + session.MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/code-frequency", repo.Link())) + session.MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/recent-commits", repo.Link())) + session.MakeRequest(t, req, http.StatusOK) +} + +func TestRepoActivityOnlyIssuesUnit(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"}) + session := loginUser(t, user.Name) + + unit_model.LoadUnitConfig() + + // Create a empty repo, with only code unit enabled. + repo, err := repo_service.CreateRepository(db.DefaultContext, user, user, repo_service.CreateRepoOptions{ + Name: "empty-repo", + AutoInit: false, + }) + require.NoError(t, err) + assert.NotEmpty(t, repo) + + enabledUnits := make([]repo_model.RepoUnit, 1) + enabledUnits[0] = repo_model.RepoUnit{RepoID: repo.ID, Type: unit_model.TypeIssues} + disabledUnits := []unit_model.Type{unit_model.TypeCode, unit_model.TypePullRequests, unit_model.TypeReleases} + err = repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, enabledUnits, disabledUnits) + require.NoError(t, err) + + req := NewRequest(t, "GET", fmt.Sprintf("%s/activity", repo.Link())) + session.MakeRequest(t, req, http.StatusOK) + + // Git repo empty so no activity for contributors etc + req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/contributors", repo.Link())) + session.MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/code-frequency", repo.Link())) + session.MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/recent-commits", repo.Link())) + session.MakeRequest(t, req, http.StatusNotFound) +} diff --git a/tests/integration/repo_archive_test.go b/tests/integration/repo_archive_test.go new file mode 100644 index 0000000..75fe78e --- /dev/null +++ b/tests/integration/repo_archive_test.go @@ -0,0 +1,34 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "io" + "net/http" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/routers/web" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepoDownloadArchive(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.EnableGzip, true)() + defer test.MockVariableValue(&web.GzipMinSize, 10)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + req := NewRequest(t, "GET", "/user2/repo1/archive/master.zip") + req.Header.Set("Accept-Encoding", "gzip") + resp := MakeRequest(t, req, http.StatusOK) + bs, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Empty(t, resp.Header().Get("Content-Encoding")) + assert.Len(t, bs, 320) +} diff --git a/tests/integration/repo_archive_text_test.go b/tests/integration/repo_archive_text_test.go new file mode 100644 index 0000000..e759246 --- /dev/null +++ b/tests/integration/repo_archive_text_test.go @@ -0,0 +1,78 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "path" + "strings" + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" +) + +func TestArchiveText(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + testUser := "user2" + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: testUser}) + session := loginUser(t, testUser) + testRepoName := "archived_repo" + tr := translation.NewLocale("en-US") + link := path.Join(testUser, testRepoName, "settings") + + // Create test repo + _, _, f := tests.CreateDeclarativeRepo(t, user2, testRepoName, nil, nil, nil) + defer f() + + // Test settings page + req := NewRequest(t, "GET", link) + resp := session.MakeRequest(t, req, http.StatusOK) + archivation := NewHTMLParser(t, resp.Body) + testRepoArchiveElements(t, tr, archivation, "archive") + + // Archive repo + req = NewRequestWithValues(t, "POST", link, map[string]string{ + "action": "archive", + "_csrf": GetCSRF(t, session, link), + }) + _ = session.MakeRequest(t, req, http.StatusSeeOther) + + // Test settings page again + req = NewRequest(t, "GET", link) + resp = session.MakeRequest(t, req, http.StatusOK) + unarchivation := NewHTMLParser(t, resp.Body) + testRepoArchiveElements(t, tr, unarchivation, "unarchive") + }) +} + +func testRepoArchiveElements(t *testing.T, tr translation.Locale, doc *HTMLDoc, opType string) { + t.Helper() + + // Test danger section + section := doc.Find(".danger.segment .flex-list .flex-item:has(.button[data-modal='#archive-repo-modal'])") + testRepoArchiveElement(t, tr, section, ".flex-item-title", opType+".header") + testRepoArchiveElement(t, tr, section, ".flex-item-body", opType+".text") + testRepoArchiveElement(t, tr, section, ".button", opType+".button") + + // Test modal + modal := doc.Find("#archive-repo-modal") + testRepoArchiveElement(t, tr, modal, ".header", opType+".header") + testRepoArchiveElement(t, tr, modal, ".message", opType+".text") + testRepoArchiveElement(t, tr, modal, ".button.red", opType+".button") +} + +func testRepoArchiveElement(t *testing.T, tr translation.Locale, doc *goquery.Selection, selector, op string) { + t.Helper() + + element := doc.Find(selector).Text() + element = strings.TrimSpace(element) + assert.Equal(t, tr.TrString("repo.settings."+op), element) +} diff --git a/tests/integration/repo_badges_test.go b/tests/integration/repo_badges_test.go new file mode 100644 index 0000000..a74d397 --- /dev/null +++ b/tests/integration/repo_badges_test.go @@ -0,0 +1,252 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + actions_model "code.gitea.io/gitea/models/actions" + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/services/release" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBadges(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + prep := func(t *testing.T) (*repo_model.Repository, func()) { + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + repo, _, f := tests.CreateDeclarativeRepo(t, owner, "", + []unit_model.Type{unit_model.TypeActions}, + []unit_model.Type{unit_model.TypeIssues, unit_model.TypePullRequests, unit_model.TypeReleases}, + []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ".gitea/workflows/pr.yml", + ContentReader: strings.NewReader("name: pr\non:\n push:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + }, + { + Operation: "create", + TreePath: ".gitea/workflows/self-test.yaml", + ContentReader: strings.NewReader("name: self-test\non:\n push:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + }, + { + Operation: "create", + TreePath: ".gitea/workflows/tag-test.yaml", + ContentReader: strings.NewReader("name: tags\non:\n push:\n tags: '*'\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"), + }, + }, + ) + assert.Equal(t, 2, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID})) + + return repo, f + } + + assertBadge := func(t *testing.T, resp *httptest.ResponseRecorder, badge string) { + t.Helper() + + assert.Equal(t, fmt.Sprintf("https://img.shields.io/badge/%s", badge), test.RedirectURL(resp)) + } + + t.Run("Workflows", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + repo, f := prep(t) + defer f() + + // Actions disabled + req := NewRequest(t, "GET", "/user2/repo1/badges/workflows/test.yaml/badge.svg") + resp := MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "test.yaml-Not%20found-crimson") + + req = NewRequest(t, "GET", "/user2/repo1/badges/workflows/test.yaml/badge.svg?branch=no-such-branch") + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "test.yaml-Not%20found-crimson") + + // Actions enabled + req = NewRequestf(t, "GET", "/user2/%s/badges/workflows/pr.yml/badge.svg", repo.Name) + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "pr.yml-waiting-lightgrey") + + req = NewRequestf(t, "GET", "/user2/%s/badges/workflows/pr.yml/badge.svg?branch=main", repo.Name) + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "pr.yml-waiting-lightgrey") + + req = NewRequestf(t, "GET", "/user2/%s/badges/workflows/pr.yml/badge.svg?branch=no-such-branch", repo.Name) + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "pr.yml-Not%20found-crimson") + + req = NewRequestf(t, "GET", "/user2/%s/badges/workflows/pr.yml/badge.svg?event=cron", repo.Name) + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "pr.yml-Not%20found-crimson") + + // Workflow with a dash in its name + req = NewRequestf(t, "GET", "/user2/%s/badges/workflows/self-test.yaml/badge.svg", repo.Name) + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "self--test.yaml-waiting-lightgrey") + + // GitHub compatibility + req = NewRequestf(t, "GET", "/user2/%s/actions/workflows/pr.yml/badge.svg", repo.Name) + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "pr.yml-waiting-lightgrey") + + req = NewRequestf(t, "GET", "/user2/%s/actions/workflows/pr.yml/badge.svg?branch=main", repo.Name) + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "pr.yml-waiting-lightgrey") + + req = NewRequestf(t, "GET", "/user2/%s/actions/workflows/pr.yml/badge.svg?branch=no-such-branch", repo.Name) + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "pr.yml-Not%20found-crimson") + + req = NewRequestf(t, "GET", "/user2/%s/actions/workflows/pr.yml/badge.svg?event=cron", repo.Name) + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "pr.yml-Not%20found-crimson") + + t.Run("tagged", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // With no tags, the workflow has no runs, and isn't found + req := NewRequestf(t, "GET", "/user2/%s/actions/workflows/tag-test.yaml/badge.svg", repo.Name) + resp := MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "tag--test.yaml-Not%20found-crimson") + + // Lets create a tag! + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + err := release.CreateNewTag(git.DefaultContext, owner, repo, "main", "v1", "message") + require.NoError(t, err) + + // Now the workflow is waiting + req = NewRequestf(t, "GET", "/user2/%s/actions/workflows/tag-test.yaml/badge.svg", repo.Name) + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "tag--test.yaml-waiting-lightgrey") + }) + }) + + t.Run("Stars", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/badges/stars.svg") + resp := MakeRequest(t, req, http.StatusSeeOther) + + assertBadge(t, resp, "stars-0-blue") + + t.Run("disabled stars", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&setting.Repository.DisableStars, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + MakeRequest(t, req, http.StatusNotFound) + }) + }) + + t.Run("Issues", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + repo, f := prep(t) + defer f() + + // Issues enabled + req := NewRequest(t, "GET", "/user2/repo1/badges/issues.svg") + resp := MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "issues-2-blue") + + req = NewRequest(t, "GET", "/user2/repo1/badges/issues/open.svg") + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "issues-1%20open-blue") + + req = NewRequest(t, "GET", "/user2/repo1/badges/issues/closed.svg") + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "issues-1%20closed-blue") + + // Issues disabled + req = NewRequestf(t, "GET", "/user2/%s/badges/issues.svg", repo.Name) + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "issues-Not%20found-crimson") + + req = NewRequestf(t, "GET", "/user2/%s/badges/issues/open.svg", repo.Name) + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "issues-Not%20found-crimson") + + req = NewRequestf(t, "GET", "/user2/%s/badges/issues/closed.svg", repo.Name) + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "issues-Not%20found-crimson") + }) + + t.Run("Pulls", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + repo, f := prep(t) + defer f() + + // Pull requests enabled + req := NewRequest(t, "GET", "/user2/repo1/badges/pulls.svg") + resp := MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "pulls-3-blue") + + req = NewRequest(t, "GET", "/user2/repo1/badges/pulls/open.svg") + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "pulls-3%20open-blue") + + req = NewRequest(t, "GET", "/user2/repo1/badges/pulls/closed.svg") + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "pulls-0%20closed-blue") + + // Pull requests disabled + req = NewRequestf(t, "GET", "/user2/%s/badges/pulls.svg", repo.Name) + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "pulls-Not%20found-crimson") + + req = NewRequestf(t, "GET", "/user2/%s/badges/pulls/open.svg", repo.Name) + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "pulls-Not%20found-crimson") + + req = NewRequestf(t, "GET", "/user2/%s/badges/pulls/closed.svg", repo.Name) + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "pulls-Not%20found-crimson") + }) + + t.Run("Release", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + repo, f := prep(t) + defer f() + + req := NewRequest(t, "GET", "/user2/repo1/badges/release.svg") + resp := MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "release-v1.1-blue") + + req = NewRequestf(t, "GET", "/user2/%s/badges/release.svg", repo.Name) + resp = MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "release-Not%20found-crimson") + + t.Run("Dashes in the name", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, repo.Owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + err := release.CreateNewTag(git.DefaultContext, repo.Owner, repo, "main", "repo-name-2.0", "dash in the tag name") + require.NoError(t, err) + createNewReleaseUsingAPI(t, token, repo.Owner, repo, "repo-name-2.0", "main", "dashed release", "dashed release") + + req := NewRequestf(t, "GET", "/user2/%s/badges/release.svg", repo.Name) + resp := MakeRequest(t, req, http.StatusSeeOther) + assertBadge(t, resp, "release-repo--name--2.0-blue") + }) + }) + }) +} diff --git a/tests/integration/repo_branch_test.go b/tests/integration/repo_branch_test.go new file mode 100644 index 0000000..df9ea9a --- /dev/null +++ b/tests/integration/repo_branch_test.go @@ -0,0 +1,206 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "path" + "strconv" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/translation" + repo_service "code.gitea.io/gitea/services/repository" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testCreateBranch(t testing.TB, session *TestSession, user, repo, oldRefSubURL, newBranchName string, expectedStatus int) string { + var csrf string + if expectedStatus == http.StatusNotFound { + csrf = GetCSRF(t, session, path.Join(user, repo, "src/branch/master")) + } else { + csrf = GetCSRF(t, session, path.Join(user, repo, "src", oldRefSubURL)) + } + req := NewRequestWithValues(t, "POST", path.Join(user, repo, "branches/_new", oldRefSubURL), map[string]string{ + "_csrf": csrf, + "new_branch_name": newBranchName, + }) + resp := session.MakeRequest(t, req, expectedStatus) + if expectedStatus != http.StatusSeeOther { + return "" + } + return test.RedirectURL(resp) +} + +func TestCreateBranch(t *testing.T) { + onGiteaRun(t, testCreateBranches) +} + +func testCreateBranches(t *testing.T, giteaURL *url.URL) { + tests := []struct { + OldRefSubURL string + NewBranch string + CreateRelease string + FlashMessage string + ExpectedStatus int + CheckBranch bool + }{ + { + OldRefSubURL: "branch/master", + NewBranch: "feature/test1", + ExpectedStatus: http.StatusSeeOther, + FlashMessage: translation.NewLocale("en-US").TrString("repo.branch.create_success", "feature/test1"), + CheckBranch: true, + }, + { + OldRefSubURL: "branch/master", + NewBranch: "", + ExpectedStatus: http.StatusSeeOther, + FlashMessage: translation.NewLocale("en-US").TrString("form.NewBranchName") + translation.NewLocale("en-US").TrString("form.require_error"), + }, + { + OldRefSubURL: "branch/master", + NewBranch: "feature=test1", + ExpectedStatus: http.StatusSeeOther, + FlashMessage: translation.NewLocale("en-US").TrString("repo.branch.create_success", "feature=test1"), + CheckBranch: true, + }, + { + OldRefSubURL: "branch/master", + NewBranch: strings.Repeat("b", 101), + ExpectedStatus: http.StatusSeeOther, + FlashMessage: translation.NewLocale("en-US").TrString("form.NewBranchName") + translation.NewLocale("en-US").TrString("form.max_size_error", "100"), + }, + { + OldRefSubURL: "branch/master", + NewBranch: "master", + ExpectedStatus: http.StatusSeeOther, + FlashMessage: translation.NewLocale("en-US").TrString("repo.branch.branch_already_exists", "master"), + }, + { + OldRefSubURL: "branch/master", + NewBranch: "master/test", + ExpectedStatus: http.StatusSeeOther, + FlashMessage: translation.NewLocale("en-US").TrString("repo.branch.branch_name_conflict", "master/test", "master"), + }, + { + OldRefSubURL: "commit/acd1d892867872cb47f3993468605b8aa59aa2e0", + NewBranch: "feature/test2", + ExpectedStatus: http.StatusNotFound, + }, + { + OldRefSubURL: "commit/65f1bf27bc3bf70f64657658635e66094edbcb4d", + NewBranch: "feature/test3", + ExpectedStatus: http.StatusSeeOther, + FlashMessage: translation.NewLocale("en-US").TrString("repo.branch.create_success", "feature/test3"), + CheckBranch: true, + }, + { + OldRefSubURL: "branch/master", + NewBranch: "v1.0.0", + CreateRelease: "v1.0.0", + ExpectedStatus: http.StatusSeeOther, + FlashMessage: translation.NewLocale("en-US").TrString("repo.branch.tag_collision", "v1.0.0"), + }, + { + OldRefSubURL: "tag/v1.0.0", + NewBranch: "feature/test4", + CreateRelease: "v1.0.1", + ExpectedStatus: http.StatusSeeOther, + FlashMessage: translation.NewLocale("en-US").TrString("repo.branch.create_success", "feature/test4"), + CheckBranch: true, + }, + } + + session := loginUser(t, "user2") + for _, test := range tests { + if test.CheckBranch { + unittest.AssertNotExistsBean(t, &git_model.Branch{RepoID: 1, Name: test.NewBranch}) + } + if test.CreateRelease != "" { + createNewRelease(t, session, "/user2/repo1", test.CreateRelease, test.CreateRelease, false, false) + } + redirectURL := testCreateBranch(t, session, "user2", "repo1", test.OldRefSubURL, test.NewBranch, test.ExpectedStatus) + if test.ExpectedStatus == http.StatusSeeOther { + req := NewRequest(t, "GET", redirectURL) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, + strings.TrimSpace(htmlDoc.doc.Find(".ui.message").Text()), + test.FlashMessage, + ) + } + if test.CheckBranch { + unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: 1, Name: test.NewBranch}) + } + } +} + +func TestCreateBranchInvalidCSRF(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + req := NewRequestWithValues(t, "POST", "user2/repo1/branches/_new/branch/master", map[string]string{ + "_csrf": "fake_csrf", + "new_branch_name": "test", + }) + resp := session.MakeRequest(t, req, http.StatusBadRequest) + assert.Contains(t, resp.Body.String(), "Invalid CSRF token") +} + +func TestDatabaseMissingABranch(t *testing.T) { + onGiteaRun(t, func(t *testing.T, URL *url.URL) { + session := loginUser(t, "user2") + + // Create two branches + testCreateBranch(t, session, "user2", "repo1", "branch/master", "will-be-present", http.StatusSeeOther) + testCreateBranch(t, session, "user2", "repo1", "branch/master", "will-be-missing", http.StatusSeeOther) + + // Run the repo branch sync, to ensure the db and git agree. + err2 := repo_service.AddAllRepoBranchesToSyncQueue(graceful.GetManager().ShutdownContext()) + require.NoError(t, err2) + + // Delete one branch from git only, leaving it in the database + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + cmd := git.NewCommand(db.DefaultContext, "branch", "-D").AddDynamicArguments("will-be-missing") + _, _, err := cmd.RunStdString(&git.RunOpts{Dir: repo.RepoPath()}) + require.NoError(t, err) + + // Verify that loading the repo's branches page works still, and that it + // reports at least three branches (master, will-be-present, and + // will-be-missing). + req := NewRequest(t, "GET", "/user2/repo1/branches") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + firstBranchCount, _ := strconv.Atoi(doc.Find(".repository-menu a[href*='/branches'] b").Text()) + assert.GreaterOrEqual(t, firstBranchCount, 3) + + // Run the repo branch sync again + err2 = repo_service.AddAllRepoBranchesToSyncQueue(graceful.GetManager().ShutdownContext()) + require.NoError(t, err2) + + // Verify that loading the repo's branches page works still, and that it + // reports one branch less than the first time. + // + // NOTE: This assumes that the branch counter on the web UI is out of + // date before the sync. If that problem gets resolved, we'll have to + // find another way to test that the syncing works. + req = NewRequest(t, "GET", "/user2/repo1/branches") + resp = session.MakeRequest(t, req, http.StatusOK) + doc = NewHTMLParser(t, resp.Body) + secondBranchCount, _ := strconv.Atoi(doc.Find(".repository-menu a[href*='/branches'] b").Text()) + assert.Equal(t, firstBranchCount-1, secondBranchCount) + }) +} diff --git a/tests/integration/repo_citation_test.go b/tests/integration/repo_citation_test.go new file mode 100644 index 0000000..4f7e24e --- /dev/null +++ b/tests/integration/repo_citation_test.go @@ -0,0 +1,81 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package integration + +import ( + "net/http" + "net/url" + "strings" + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestCitation(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + session := loginUser(t, user.LoginName) + + t.Run("No citation", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo, _, f := tests.CreateDeclarativeRepo(t, user, "citation-no-citation", []unit_model.Type{unit_model.TypeCode}, nil, nil) + defer f() + + testCitationButtonExists(t, session, repo, "", false) + }) + + t.Run("cff citation", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo, f := createRepoWithEmptyFile(t, user, "citation-cff", "CITATION.cff") + defer f() + + testCitationButtonExists(t, session, repo, "CITATION.cff", true) + }) + + t.Run("bib citation", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo, f := createRepoWithEmptyFile(t, user, "citation-bib", "CITATION.bib") + defer f() + + testCitationButtonExists(t, session, repo, "CITATION.bib", true) + }) + }) +} + +func testCitationButtonExists(t *testing.T, session *TestSession, repo *repo_model.Repository, file string, exists bool) { + req := NewRequest(t, "GET", repo.HTMLURL()) + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + doc.AssertElement(t, "#cite-repo-button", exists) + + if exists { + href, exists := doc.doc.Find("#goto-citation-btn").Attr("href") + assert.True(t, exists) + + assert.True(t, strings.HasSuffix(href, file)) + } +} + +func createRepoWithEmptyFile(t *testing.T, user *user_model.User, repoName, fileName string) (*repo_model.Repository, func()) { + repo, _, f := tests.CreateDeclarativeRepo(t, user, repoName, []unit_model.Type{unit_model.TypeCode}, nil, []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: fileName, + }, + }) + + return repo, f +} diff --git a/tests/integration/repo_collaborator_test.go b/tests/integration/repo_collaborator_test.go new file mode 100644 index 0000000..beeb950 --- /dev/null +++ b/tests/integration/repo_collaborator_test.go @@ -0,0 +1,37 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestRepoCollaborators is a test for contents of Collaborators tab in the repo settings +// It only covers a few elements and can be extended as needed +func TestRepoCollaborators(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user2") + + // Visit Collaborators tab of repo settings + response := session.MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/settings/collaboration"), http.StatusOK) + page := NewHTMLParser(t, response.Body).Find(".repo-setting-content") + + // Veirfy header + assert.EqualValues(t, "Collaborators", strings.TrimSpace(page.Find("h4").Text())) + + // Veirfy button text + page = page.Find("#repo-collab-form") + assert.EqualValues(t, "Add collaborator", strings.TrimSpace(page.Find("button.primary").Text())) + + // Veirfy placeholder + placeholder, exists := page.Find("#search-user-box input").Attr("placeholder") + assert.True(t, exists) + assert.EqualValues(t, "Search users...", placeholder) + }) +} diff --git a/tests/integration/repo_commits_search_test.go b/tests/integration/repo_commits_search_test.go new file mode 100644 index 0000000..74ac25c --- /dev/null +++ b/tests/integration/repo_commits_search_test.go @@ -0,0 +1,44 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "strings" + "testing" + + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func testRepoCommitsSearch(t *testing.T, query, commit string) { + session := loginUser(t, "user2") + + // Request repository commits page + req := NewRequestf(t, "GET", "/user2/commits_search_test/commits/branch/master/search?q=%s", url.QueryEscape(query)) + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + sel := doc.doc.Find("#commits-table tbody tr td.sha a") + assert.EqualValues(t, commit, strings.TrimSpace(sel.Text())) +} + +func TestRepoCommitsSearch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + testRepoCommitsSearch(t, "e8eabd", "") + testRepoCommitsSearch(t, "38a9cb", "") + testRepoCommitsSearch(t, "6e8e", "6e8eabd9a7") + testRepoCommitsSearch(t, "58e97", "58e97d1a24") + testRepoCommitsSearch(t, "[build]", "") + testRepoCommitsSearch(t, "author:alice", "6e8eabd9a7") + testRepoCommitsSearch(t, "author:alice 6e8ea", "6e8eabd9a7") + testRepoCommitsSearch(t, "committer:Tom", "58e97d1a24") + testRepoCommitsSearch(t, "author:bob commit-4", "58e97d1a24") + testRepoCommitsSearch(t, "author:bob commit after:2019-03-03", "58e97d1a24") + testRepoCommitsSearch(t, "committer:alice 6e8e before:2019-03-02", "6e8eabd9a7") + testRepoCommitsSearch(t, "committer:alice commit before:2019-03-02", "6e8eabd9a7") + testRepoCommitsSearch(t, "committer:alice author:tom commit before:2019-03-04 after:2019-03-02", "0a8499a22a") +} diff --git a/tests/integration/repo_commits_test.go b/tests/integration/repo_commits_test.go new file mode 100644 index 0000000..e399898 --- /dev/null +++ b/tests/integration/repo_commits_test.go @@ -0,0 +1,206 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/http/httptest" + "path" + "sync" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepoCommits(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + // Request repository commits page + req := NewRequest(t, "GET", "/user2/repo1/commits/branch/master") + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + commitURL, exists := doc.doc.Find("#commits-table tbody tr td.sha a").Attr("href") + assert.True(t, exists) + assert.NotEmpty(t, commitURL) +} + +func doTestRepoCommitWithStatus(t *testing.T, state string, classes ...string) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + // Request repository commits page + req := NewRequest(t, "GET", "/user2/repo1/commits/branch/master") + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + // Get first commit URL + commitURL, exists := doc.doc.Find("#commits-table tbody tr td.sha a").Attr("href") + assert.True(t, exists) + assert.NotEmpty(t, commitURL) + + // Call API to add status for commit + ctx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository) + t.Run("CreateStatus", doAPICreateCommitStatus(ctx, path.Base(commitURL), api.CreateStatusOption{ + State: api.CommitStatusState(state), + TargetURL: "http://test.ci/", + Description: "", + Context: "testci", + })) + + req = NewRequest(t, "GET", "/user2/repo1/commits/branch/master") + resp = session.MakeRequest(t, req, http.StatusOK) + + doc = NewHTMLParser(t, resp.Body) + // Check if commit status is displayed in message column (.tippy-target to ignore the tippy trigger) + sel := doc.doc.Find("#commits-table tbody tr td.message .tippy-target .commit-status") + assert.Equal(t, 1, sel.Length()) + for _, class := range classes { + assert.True(t, sel.HasClass(class)) + } + + // By SHA + req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/"+path.Base(commitURL)+"/statuses") + reqOne := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/"+path.Base(commitURL)+"/status") + testRepoCommitsWithStatus(t, session.MakeRequest(t, req, http.StatusOK), session.MakeRequest(t, reqOne, http.StatusOK), state) + + // By short SHA + req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/"+path.Base(commitURL)[:10]+"/statuses") + reqOne = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/"+path.Base(commitURL)[:10]+"/status") + testRepoCommitsWithStatus(t, session.MakeRequest(t, req, http.StatusOK), session.MakeRequest(t, reqOne, http.StatusOK), state) + + // By Ref + req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/master/statuses") + reqOne = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/master/status") + testRepoCommitsWithStatus(t, session.MakeRequest(t, req, http.StatusOK), session.MakeRequest(t, reqOne, http.StatusOK), state) + req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/v1.1/statuses") + reqOne = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/v1.1/status") + testRepoCommitsWithStatus(t, session.MakeRequest(t, req, http.StatusOK), session.MakeRequest(t, reqOne, http.StatusOK), state) +} + +func testRepoCommitsWithStatus(t *testing.T, resp, respOne *httptest.ResponseRecorder, state string) { + var statuses []*api.CommitStatus + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &statuses)) + var status api.CombinedStatus + require.NoError(t, json.Unmarshal(respOne.Body.Bytes(), &status)) + assert.NotNil(t, status) + + if assert.Len(t, statuses, 1) { + assert.Equal(t, api.CommitStatusState(state), statuses[0].State) + assert.Equal(t, setting.AppURL+"api/v1/repos/user2/repo1/statuses/65f1bf27bc3bf70f64657658635e66094edbcb4d", statuses[0].URL) + assert.Equal(t, "http://test.ci/", statuses[0].TargetURL) + assert.Equal(t, "", statuses[0].Description) + assert.Equal(t, "testci", statuses[0].Context) + + assert.Len(t, status.Statuses, 1) + assert.Equal(t, statuses[0], status.Statuses[0]) + assert.Equal(t, "65f1bf27bc3bf70f64657658635e66094edbcb4d", status.SHA) + } +} + +func TestRepoCommitsWithStatusPending(t *testing.T) { + doTestRepoCommitWithStatus(t, "pending", "octicon-dot-fill", "yellow") +} + +func TestRepoCommitsWithStatusSuccess(t *testing.T) { + doTestRepoCommitWithStatus(t, "success", "octicon-check", "green") +} + +func TestRepoCommitsWithStatusError(t *testing.T) { + doTestRepoCommitWithStatus(t, "error", "gitea-exclamation", "red") +} + +func TestRepoCommitsWithStatusFailure(t *testing.T) { + doTestRepoCommitWithStatus(t, "failure", "octicon-x", "red") +} + +func TestRepoCommitsWithStatusWarning(t *testing.T) { + doTestRepoCommitWithStatus(t, "warning", "gitea-exclamation", "yellow") +} + +func TestRepoCommitsStatusParallel(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + // Request repository commits page + req := NewRequest(t, "GET", "/user2/repo1/commits/branch/master") + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + // Get first commit URL + commitURL, exists := doc.doc.Find("#commits-table tbody tr td.sha a").Attr("href") + assert.True(t, exists) + assert.NotEmpty(t, commitURL) + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func(parentT *testing.T, i int) { + parentT.Run(fmt.Sprintf("ParallelCreateStatus_%d", i), func(t *testing.T) { + ctx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository) + runBody := doAPICreateCommitStatus(ctx, path.Base(commitURL), api.CreateStatusOption{ + State: api.CommitStatusPending, + TargetURL: "http://test.ci/", + Description: "", + Context: "testci", + }) + runBody(t) + wg.Done() + }) + }(t, i) + } + wg.Wait() +} + +func TestRepoCommitsStatusMultiple(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + // Request repository commits page + req := NewRequest(t, "GET", "/user2/repo1/commits/branch/master") + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + // Get first commit URL + commitURL, exists := doc.doc.Find("#commits-table tbody tr td.sha a").Attr("href") + assert.True(t, exists) + assert.NotEmpty(t, commitURL) + + // Call API to add status for commit + ctx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository) + t.Run("CreateStatus", doAPICreateCommitStatus(ctx, path.Base(commitURL), api.CreateStatusOption{ + State: api.CommitStatusSuccess, + TargetURL: "http://test.ci/", + Description: "", + Context: "testci", + })) + + t.Run("CreateStatus", doAPICreateCommitStatus(ctx, path.Base(commitURL), api.CreateStatusOption{ + State: api.CommitStatusSuccess, + TargetURL: "http://test.ci/", + Description: "", + Context: "other_context", + })) + + req = NewRequest(t, "GET", "/user2/repo1/commits/branch/master") + resp = session.MakeRequest(t, req, http.StatusOK) + + doc = NewHTMLParser(t, resp.Body) + // Check that the data-tippy="commit-statuses" (for trigger) and commit-status (svg) are present + sel := doc.doc.Find("#commits-table tbody tr td.message [data-tippy=\"commit-statuses\"] .commit-status") + assert.Equal(t, 1, sel.Length()) +} diff --git a/tests/integration/repo_delete_test.go b/tests/integration/repo_delete_test.go new file mode 100644 index 0000000..44ef26f --- /dev/null +++ b/tests/integration/repo_delete_test.go @@ -0,0 +1,74 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + webhook_model "code.gitea.io/gitea/models/webhook" + repo_service "code.gitea.io/gitea/services/repository" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTeam_HasRepository(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + test := func(teamID, repoID int64, expected bool) { + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) + assert.Equal(t, expected, repo_service.HasRepository(db.DefaultContext, team, repoID)) + } + test(1, 1, false) + test(1, 3, true) + test(1, 5, true) + test(1, unittest.NonexistentID, false) + + test(2, 3, true) + test(2, 5, false) +} + +func TestTeam_RemoveRepository(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + testSuccess := func(teamID, repoID int64) { + team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) + require.NoError(t, repo_service.RemoveRepositoryFromTeam(db.DefaultContext, team, repoID)) + unittest.AssertNotExistsBean(t, &organization.TeamRepo{TeamID: teamID, RepoID: repoID}) + unittest.CheckConsistencyFor(t, &organization.Team{ID: teamID}, &repo_model.Repository{ID: repoID}) + } + testSuccess(2, 3) + testSuccess(2, 5) + testSuccess(1, unittest.NonexistentID) +} + +func TestDeleteOwnerRepositoriesDirectly(t *testing.T) { + unittest.PrepareTestEnv(t) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + deletedHookID := unittest.AssertExistsAndLoadBean(t, &webhook_model.Webhook{RepoID: 1}).ID + unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{ + HookID: deletedHookID, + }) + + preservedHookID := unittest.AssertExistsAndLoadBean(t, &webhook_model.Webhook{RepoID: 3}).ID + unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{ + HookID: preservedHookID, + }) + + require.NoError(t, repo_service.DeleteOwnerRepositoriesDirectly(db.DefaultContext, user)) + + unittest.AssertNotExistsBean(t, &webhook_model.HookTask{ + HookID: deletedHookID, + }) + unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{ + HookID: preservedHookID, + }) +} diff --git a/tests/integration/repo_flags_test.go b/tests/integration/repo_flags_test.go new file mode 100644 index 0000000..8b64776 --- /dev/null +++ b/tests/integration/repo_flags_test.go @@ -0,0 +1,391 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/http/httptest" + "slices" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestRepositoryFlagsUIDisabled(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Repository.EnableFlags, false)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) + session := loginUser(t, admin.Name) + + // With the repo flags feature disabled, the /flags route is 404 + req := NewRequest(t, "GET", "/user2/repo1/flags") + session.MakeRequest(t, req, http.StatusNotFound) + + // With the repo flags feature disabled, the "Modify flags" tab does not + // appear for instance admins + req = NewRequest(t, "GET", "/user2/repo1") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + flagsLinkCount := doc.Find(fmt.Sprintf(`a[href="%s/flags"]`, "/user2/repo1")).Length() + assert.Equal(t, 0, flagsLinkCount) +} + +func TestRepositoryFlagsAPI(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Repository.EnableFlags, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + // ************* + // ** Helpers ** + // ************* + + adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}).Name + normalUserBean := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + assert.False(t, normalUserBean.IsAdmin) + normalUser := normalUserBean.Name + + assertAccess := func(t *testing.T, user, method, uri string, expectedStatus int) { + session := loginUser(t, user) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeReadAdmin) + + req := NewRequestf(t, method, "/api/v1/repos/user2/repo1/flags%s", uri).AddTokenAuth(token) + MakeRequest(t, req, expectedStatus) + } + + // *********** + // ** Tests ** + // *********** + + t.Run("API access", func(t *testing.T) { + t.Run("as admin", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assertAccess(t, adminUser, "GET", "", http.StatusOK) + }) + + t.Run("as normal user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assertAccess(t, normalUser, "GET", "", http.StatusForbidden) + }) + }) + + t.Run("token scopes", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Trying to access the API with a token that lacks permissions, will + // fail, even if the token owner is an instance admin. + session := loginUser(t, adminUser) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/flags").AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("setting.Repository.EnableFlags is respected", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&setting.Repository.EnableFlags, false)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + t.Run("as admin", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assertAccess(t, adminUser, "GET", "", http.StatusNotFound) + }) + + t.Run("as normal user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assertAccess(t, normalUser, "GET", "", http.StatusNotFound) + }) + }) + + t.Run("API functionality", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + defer func() { + repo.ReplaceAllFlags(db.DefaultContext, []string{}) + }() + + baseURLFmtStr := "/api/v1/repos/user5/repo4/flags%s" + + session := loginUser(t, adminUser) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteAdmin) + + // Listing flags + req := NewRequestf(t, "GET", baseURLFmtStr, "").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var flags []string + DecodeJSON(t, resp, &flags) + assert.Empty(t, flags) + + // Replacing all tags works, twice in a row + for i := 0; i < 2; i++ { + req = NewRequestWithJSON(t, "PUT", fmt.Sprintf(baseURLFmtStr, ""), &api.ReplaceFlagsOption{ + Flags: []string{"flag-1", "flag-2", "flag-3"}, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + } + + // The list now includes all three flags + req = NewRequestf(t, "GET", baseURLFmtStr, "").AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &flags) + assert.Len(t, flags, 3) + for _, flag := range []string{"flag-1", "flag-2", "flag-3"} { + assert.True(t, slices.Contains(flags, flag)) + } + + // Check a flag that is on the repo + req = NewRequestf(t, "GET", baseURLFmtStr, "/flag-1").AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // Check a flag that isn't on the repo + req = NewRequestf(t, "GET", baseURLFmtStr, "/no-such-flag").AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) + + // We can add the same flag twice + for i := 0; i < 2; i++ { + req = NewRequestf(t, "PUT", baseURLFmtStr, "/brand-new-flag").AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + } + + // The new flag is there + req = NewRequestf(t, "GET", baseURLFmtStr, "/brand-new-flag").AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // We can delete a flag, twice + for i := 0; i < 2; i++ { + req = NewRequestf(t, "DELETE", baseURLFmtStr, "/flag-3").AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + } + + // We can delete a flag that wasn't there + req = NewRequestf(t, "DELETE", baseURLFmtStr, "/no-such-flag").AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // We can delete all of the flags in one go, too + req = NewRequestf(t, "DELETE", baseURLFmtStr, "").AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // ..once all flags are deleted, none are listed, either + req = NewRequestf(t, "GET", baseURLFmtStr, "").AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &flags) + assert.Empty(t, flags) + }) +} + +func TestRepositoryFlagsUI(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Repository.EnableFlags, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + // ******************* + // ** Preparations ** + // ******************* + flaggedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + unflaggedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + + // ************** + // ** Helpers ** + // ************** + + adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}).Name + flaggedOwner := "user2" + flaggedRepoURLStr := "/user2/repo1" + unflaggedOwner := "user5" + unflaggedRepoURLStr := "/user5/repo4" + otherUser := "user4" + + ensureFlags := func(repo *repo_model.Repository, flags []string) func() { + repo.ReplaceAllFlags(db.DefaultContext, flags) + + return func() { + repo.ReplaceAllFlags(db.DefaultContext, flags) + } + } + + // Tests: + // - Presence of the link + // - Number of flags listed in the admin-only message box + // - Whether there's a link to /user/repo/flags + // - Whether /user/repo/flags is OK or Forbidden + assertFlagAccessAndCount := func(t *testing.T, user, repoURL string, hasAccess bool, expectedFlagCount int) { + t.Helper() + + var expectedLinkCount int + var expectedStatus int + if hasAccess { + expectedLinkCount = 1 + expectedStatus = http.StatusOK + } else { + expectedLinkCount = 0 + if user != "" { + expectedStatus = http.StatusForbidden + } else { + expectedStatus = http.StatusSeeOther + } + } + + var resp *httptest.ResponseRecorder + var session *TestSession + req := NewRequest(t, "GET", repoURL) + if user != "" { + session = loginUser(t, user) + resp = session.MakeRequest(t, req, http.StatusOK) + } else { + resp = MakeRequest(t, req, http.StatusOK) + } + doc := NewHTMLParser(t, resp.Body) + + flagsLinkCount := doc.Find(fmt.Sprintf(`a[href="%s/flags"]`, repoURL)).Length() + assert.Equal(t, expectedLinkCount, flagsLinkCount) + + flagCount := doc.Find(".ui.info.message .ui.label").Length() + assert.Equal(t, expectedFlagCount, flagCount) + + req = NewRequest(t, "GET", fmt.Sprintf("%s/flags", repoURL)) + if user != "" { + session.MakeRequest(t, req, expectedStatus) + } else { + MakeRequest(t, req, expectedStatus) + } + } + + // Ensures that given a repo owner and a repo: + // - An instance admin has access to flags, and sees the list on the repo home + // - A repo admin does not have access to either, and does not see the list + // - A passer by has no access to either, and does not see the list + runTests := func(t *testing.T, ownerUser, repoURL string, expectedFlagCount int) { + t.Run("as instance admin", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assertFlagAccessAndCount(t, adminUser, repoURL, true, expectedFlagCount) + }) + t.Run("as owner", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assertFlagAccessAndCount(t, ownerUser, repoURL, false, 0) + }) + t.Run("as other user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assertFlagAccessAndCount(t, otherUser, repoURL, false, 0) + }) + t.Run("as non-logged in user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assertFlagAccessAndCount(t, "", repoURL, false, 0) + }) + } + + // ************************** + // ** The tests themselves ** + // ************************** + t.Run("unflagged repo", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer ensureFlags(unflaggedRepo, []string{})() + + runTests(t, unflaggedOwner, unflaggedRepoURLStr, 0) + }) + + t.Run("flagged repo", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer ensureFlags(flaggedRepo, []string{"test-flag"})() + + runTests(t, flaggedOwner, flaggedRepoURLStr, 1) + }) + + t.Run("modifying flags", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, adminUser) + flaggedRepoManageURL := fmt.Sprintf("%s/flags", flaggedRepoURLStr) + unflaggedRepoManageURL := fmt.Sprintf("%s/flags", unflaggedRepoURLStr) + + assertUIFlagStates := func(t *testing.T, url string, flagStates map[string]bool) { + t.Helper() + + req := NewRequest(t, "GET", url) + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + flagBoxes := doc.Find(`input[name="flags"]`) + assert.Equal(t, len(flagStates), flagBoxes.Length()) + + for name, state := range flagStates { + _, checked := doc.Find(fmt.Sprintf(`input[value="%s"]`, name)).Attr("checked") + assert.Equal(t, state, checked) + } + } + + t.Run("flag presence on the UI", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer ensureFlags(flaggedRepo, []string{"test-flag"})() + + assertUIFlagStates(t, flaggedRepoManageURL, map[string]bool{"test-flag": true}) + }) + + t.Run("setting.Repository.SettableFlags is respected", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&setting.Repository.SettableFlags, []string{"featured", "no-license"})() + defer ensureFlags(flaggedRepo, []string{"test-flag"})() + + assertUIFlagStates(t, flaggedRepoManageURL, map[string]bool{ + "test-flag": true, + "featured": false, + "no-license": false, + }) + }) + + t.Run("removing flags", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer ensureFlags(flaggedRepo, []string{"test-flag"})() + + flagged := flaggedRepo.IsFlagged(db.DefaultContext) + assert.True(t, flagged) + + req := NewRequestWithValues(t, "POST", flaggedRepoManageURL, map[string]string{ + "_csrf": GetCSRF(t, session, flaggedRepoManageURL), + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + flagged = flaggedRepo.IsFlagged(db.DefaultContext) + assert.False(t, flagged) + + assertUIFlagStates(t, flaggedRepoManageURL, map[string]bool{}) + }) + + t.Run("adding flags", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer ensureFlags(unflaggedRepo, []string{})() + + flagged := unflaggedRepo.IsFlagged(db.DefaultContext) + assert.False(t, flagged) + + req := NewRequestWithValues(t, "POST", unflaggedRepoManageURL, map[string]string{ + "_csrf": GetCSRF(t, session, unflaggedRepoManageURL), + "flags": "test-flag", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + assertUIFlagStates(t, unflaggedRepoManageURL, map[string]bool{"test-flag": true}) + }) + }) +} diff --git a/tests/integration/repo_fork_test.go b/tests/integration/repo_fork_test.go new file mode 100644 index 0000000..b2e4067 --- /dev/null +++ b/tests/integration/repo_fork_test.go @@ -0,0 +1,272 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + repo_service "code.gitea.io/gitea/services/repository" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testRepoFork(t *testing.T, session *TestSession, ownerName, repoName, forkOwnerName, forkRepoName string) *httptest.ResponseRecorder { + t.Helper() + + forkOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: forkOwnerName}) + + // Step0: check the existence of the to-fork repo + req := NewRequestf(t, "GET", "/%s/%s", forkOwnerName, forkRepoName) + session.MakeRequest(t, req, http.StatusNotFound) + + // Step1: visit the /fork page + forkURL := fmt.Sprintf("/%s/%s/fork", ownerName, repoName) + req = NewRequest(t, "GET", forkURL) + resp := session.MakeRequest(t, req, http.StatusOK) + + // Step2: fill the form of the forking + htmlDoc := NewHTMLParser(t, resp.Body) + link, exists := htmlDoc.doc.Find(fmt.Sprintf("form.ui.form[action=\"%s\"]", forkURL)).Attr("action") + assert.True(t, exists, "The template has changed") + _, exists = htmlDoc.doc.Find(fmt.Sprintf(".owner.dropdown .item[data-value=\"%d\"]", forkOwner.ID)).Attr("data-value") + assert.True(t, exists, "Fork owner %q is not present in select box", forkOwnerName) + req = NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "uid": fmt.Sprintf("%d", forkOwner.ID), + "repo_name": forkRepoName, + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + // Step3: check the existence of the forked repo + req = NewRequestf(t, "GET", "/%s/%s", forkOwnerName, forkRepoName) + resp = session.MakeRequest(t, req, http.StatusOK) + + return resp +} + +func testRepoForkLegacyRedirect(t *testing.T, session *TestSession, ownerName, repoName string) { + t.Helper() + + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: ownerName}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: owner.ID, Name: repoName}) + + // Visit the /repo/fork/:id url + req := NewRequestf(t, "GET", "/repo/fork/%d", repo.ID) + resp := session.MakeRequest(t, req, http.StatusMovedPermanently) + + assert.Equal(t, repo.Link()+"/fork", resp.Header().Get("Location")) +} + +func TestRepoFork(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"}) + session := loginUser(t, user5.Name) + + t.Run("by name", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer func() { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user5.ID, Name: "repo1"}) + repo_service.DeleteRepository(db.DefaultContext, user5, repo, false) + }() + testRepoFork(t, session, "user2", "repo1", "user5", "repo1") + }) + + t.Run("legacy redirect", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + testRepoForkLegacyRedirect(t, session, "user2", "repo1") + + t.Run("private 404", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Make sure the repo we try to fork is private + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 31, IsPrivate: true}) + + // user5 does not have access to user2/repo20 + req := NewRequestf(t, "GET", "/repo/fork/%d", repo.ID) // user2/repo20 + session.MakeRequest(t, req, http.StatusNotFound) + }) + t.Run("authenticated private redirect", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Make sure the repo we try to fork is private + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 31, IsPrivate: true}) + + // user1 has access to user2/repo20 + session := loginUser(t, "user1") + req := NewRequestf(t, "GET", "/repo/fork/%d", repo.ID) // user2/repo20 + session.MakeRequest(t, req, http.StatusMovedPermanently) + }) + t.Run("no code unit", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Make sure the repo we try to fork is private. + // We're also choosing user15/big_test_private_2, because it has the Code unit disabled. + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 20, IsPrivate: true}) + + // user1, even though an admin, can't fork a repo without a code unit. + session := loginUser(t, "user1") + req := NewRequestf(t, "GET", "/repo/fork/%d", repo.ID) // user15/big_test_private_2 + session.MakeRequest(t, req, http.StatusNotFound) + }) + }) + + t.Run("fork button", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + forkButton := htmlDoc.Find("a[href*='/forks']") + assert.EqualValues(t, 1, forkButton.Length()) + + href, _ := forkButton.Attr("href") + assert.Equal(t, "/user2/repo1/forks", href) + assert.Equal(t, "0", strings.TrimSpace(forkButton.Text())) + + t.Run("no fork button on empty repo", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create an empty repository + repo, err := repo_service.CreateRepository(db.DefaultContext, user5, user5, repo_service.CreateRepoOptions{ + Name: "empty-repo", + AutoInit: false, + }) + defer func() { + repo_service.DeleteRepository(db.DefaultContext, user5, repo, false) + }() + require.NoError(t, err) + assert.NotEmpty(t, repo) + + // Load the repository home view + req := NewRequest(t, "GET", repo.HTMLURL()) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + // On an empty repo, the fork button is not present + htmlDoc.AssertElement(t, ".basic.button[href*='/fork']", false) + }) + }) + + t.Run("DISABLE_FORKS", func(t *testing.T) { + defer test.MockVariableValue(&setting.Repository.DisableForks, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + t.Run("fork button not present", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // The "Fork" button should not appear on the repo home + req := NewRequest(t, "GET", "/user2/repo1") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, "[href=/user2/repo1/fork]", false) + }) + + t.Run("forking by URL", func(t *testing.T) { + t.Run("by name", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Forking by URL should be Not Found + req := NewRequest(t, "GET", "/user2/repo1/fork") + session.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("by legacy URL", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Forking by legacy URL should be Not Found + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // user2/repo1 + req := NewRequestf(t, "GET", "/repo/fork/%d", repo.ID) + session.MakeRequest(t, req, http.StatusNotFound) + }) + }) + + t.Run("fork listing", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Listing the forks should be Not Found, too + req := NewRequest(t, "GET", "/user2/repo1/forks") + MakeRequest(t, req, http.StatusNotFound) + }) + }) + }) +} + +func TestRepoForkToOrg(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user2") + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org3"}) + + t.Run("by name", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer func() { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: org3.ID, Name: "repo1"}) + repo_service.DeleteRepository(db.DefaultContext, org3, repo, false) + }() + + testRepoFork(t, session, "user2", "repo1", "org3", "repo1") + + // Check that no more forking is allowed as user2 owns repository + // and org3 organization that owner user2 is also now has forked this repository + req := NewRequest(t, "GET", "/user2/repo1") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + _, exists := htmlDoc.doc.Find("a.ui.button[href^=\"/fork\"]").Attr("href") + assert.False(t, exists, "Forking should not be allowed anymore") + }) + + t.Run("legacy redirect", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testRepoForkLegacyRedirect(t, session, "user2", "repo1") + }) + }) +} + +func TestForkListPrivateRepo(t *testing.T) { + forkItemSelector := ".tw-flex.tw-items-center.tw-py-2" + + onGiteaRun(t, func(t *testing.T, u *url.URL) { + session := loginUser(t, "user5") + org23 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23, Visibility: structs.VisibleTypePrivate}) + + testRepoFork(t, session, "user2", "repo1", org23.Name, "repo1") + + t.Run("Anomynous", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/forks") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, forkItemSelector, false) + }) + + t.Run("Logged in", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/forks") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, forkItemSelector, true) + }) + }) +} diff --git a/tests/integration/repo_generate_test.go b/tests/integration/repo_generate_test.go new file mode 100644 index 0000000..c475c92 --- /dev/null +++ b/tests/integration/repo_generate_test.go @@ -0,0 +1,137 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func assertRepoCreateForm(t *testing.T, htmlDoc *HTMLDoc, owner *user_model.User, templateID string) { + _, exists := htmlDoc.doc.Find("form.ui.form[action^='/repo/create']").Attr("action") + assert.True(t, exists, "Expected the repo creation form") + locale := translation.NewLocale("en-US") + + // Verify page title + title := htmlDoc.doc.Find("title").Text() + assert.Contains(t, title, locale.TrString("new_repo.title")) + + // Verify form header + header := strings.TrimSpace(htmlDoc.doc.Find(".form[action='/repo/create'] .header").Text()) + assert.EqualValues(t, locale.TrString("new_repo.title"), header) + + htmlDoc.AssertDropdownHasSelectedOption(t, "uid", strconv.FormatInt(owner.ID, 10)) + + // the template menu is loaded client-side, so don't assert the option exists + assert.Equal(t, templateID, htmlDoc.GetInputValueByName("repo_template"), "Unexpected repo_template selection") + + for _, name := range []string{"issue_labels", "gitignores", "license", "readme", "object_format_name"} { + htmlDoc.AssertDropdownHasOptions(t, name) + } +} + +func testRepoGenerate(t *testing.T, session *TestSession, templateID, templateOwnerName, templateRepoName string, user, generateOwner *user_model.User, generateRepoName string) { + // Step0: check the existence of the generated repo + req := NewRequestf(t, "GET", "/%s/%s", generateOwner.Name, generateRepoName) + session.MakeRequest(t, req, http.StatusNotFound) + + // Step1: go to the main page of template repo + req = NewRequestf(t, "GET", "/%s/%s", templateOwnerName, templateRepoName) + resp := session.MakeRequest(t, req, http.StatusOK) + + // Step2: click the "Use this template" button + htmlDoc := NewHTMLParser(t, resp.Body) + link, exists := htmlDoc.doc.Find("a.ui.button[href^=\"/repo/create\"]").Attr("href") + assert.True(t, exists, "The template has changed") + req = NewRequest(t, "GET", link) + resp = session.MakeRequest(t, req, http.StatusOK) + + // Step3: test and submit form + htmlDoc = NewHTMLParser(t, resp.Body) + assertRepoCreateForm(t, htmlDoc, user, templateID) + req = NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "uid": fmt.Sprintf("%d", generateOwner.ID), + "repo_name": generateRepoName, + "repo_template": templateID, + "git_content": "true", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + // Step4: check the existence of the generated repo + req = NewRequestf(t, "GET", "/%s/%s", generateOwner.Name, generateRepoName) + session.MakeRequest(t, req, http.StatusOK) + + // Step5: check substituted values in Readme + req = NewRequestf(t, "GET", "/%s/%s/raw/branch/master/README.md", generateOwner.Name, generateRepoName) + resp = session.MakeRequest(t, req, http.StatusOK) + body := fmt.Sprintf(`# %s Readme +Owner: %s +Link: /%s/%s +Clone URL: %s%s/%s.git`, + generateRepoName, + strings.ToUpper(generateOwner.Name), + generateOwner.Name, + generateRepoName, + setting.AppURL, + generateOwner.Name, + generateRepoName) + assert.Equal(t, body, resp.Body.String()) + + // Step6: check substituted values in substituted file path ${REPO_NAME} + req = NewRequestf(t, "GET", "/%s/%s/raw/branch/master/%s.log", generateOwner.Name, generateRepoName, generateRepoName) + resp = session.MakeRequest(t, req, http.StatusOK) + assert.Equal(t, generateRepoName, resp.Body.String()) +} + +// test form elements before and after POST error response +func TestRepoCreateForm(t *testing.T) { + defer tests.PrepareTestEnv(t)() + userName := "user1" + session := loginUser(t, userName) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: userName}) + + req := NewRequest(t, "GET", "/repo/create") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assertRepoCreateForm(t, htmlDoc, user, "") + + req = NewRequestWithValues(t, "POST", "/repo/create", map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + }) + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + assertRepoCreateForm(t, htmlDoc, user, "") +} + +func TestRepoGenerate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + userName := "user1" + session := loginUser(t, userName) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: userName}) + + testRepoGenerate(t, session, "44", "user27", "template1", user, user, "generated1") +} + +func TestRepoGenerateToOrg(t *testing.T) { + defer tests.PrepareTestEnv(t)() + userName := "user2" + session := loginUser(t, userName) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: userName}) + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org3"}) + + testRepoGenerate(t, session, "44", "user27", "template1", user, org, "generated2") +} diff --git a/tests/integration/repo_issue_title_test.go b/tests/integration/repo_issue_title_test.go new file mode 100644 index 0000000..5199be9 --- /dev/null +++ b/tests/integration/repo_issue_title_test.go @@ -0,0 +1,162 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package integration + +import ( + "net/http" + "net/url" + "strings" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + issue_service "code.gitea.io/gitea/services/issue" + pull_service "code.gitea.io/gitea/services/pull" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIssueTitles(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + repo, _, f := tests.CreateDeclarativeRepo(t, user, "issue-titles", nil, nil, nil) + defer f() + + session := loginUser(t, user.LoginName) + + title := "Title :+1: `code`" + issue1 := createIssue(t, user, repo, title, "Test issue") + issue2 := createIssue(t, user, repo, title, "Ref #1") + + titleHTML := []string{ + "Title", + `<span class="emoji" aria-label="thumbs up">👍</span>`, + `<code class="inline-code-block">code</code>`, + } + + t.Run("Main issue title", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + html := extractHTML(t, session, issue1, "div.issue-title-header > * > h1") + assertContainsAll(t, titleHTML, html) + }) + + t.Run("Referenced issue comment", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + html := extractHTML(t, session, issue1, "div.timeline > div.timeline-item:nth-child(3) > div.detail > * > a") + assertContainsAll(t, titleHTML, html) + }) + + t.Run("Dependent issue comment", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + err := issues_model.CreateIssueDependency(db.DefaultContext, user, issue1, issue2) + require.NoError(t, err) + + html := extractHTML(t, session, issue1, "div.timeline > div:nth-child(3) > div.detail > * > a") + assertContainsAll(t, titleHTML, html) + }) + + t.Run("Dependent issue sidebar", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + html := extractHTML(t, session, issue1, "div.item.dependency > * > a.title") + assertContainsAll(t, titleHTML, html) + }) + + t.Run("Referenced pull comment", func(t *testing.T) { + _, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user, &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "update", + TreePath: "README.md", + ContentReader: strings.NewReader("Update README"), + }, + }, + Message: "Update README", + OldBranch: "main", + NewBranch: "branch", + Author: &files_service.IdentityOptions{ + Name: user.Name, + Email: user.Email, + }, + Committer: &files_service.IdentityOptions{ + Name: user.Name, + Email: user.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + + require.NoError(t, err) + + pullIssue := &issues_model.Issue{ + RepoID: repo.ID, + Title: title, + Content: "Closes #1", + PosterID: user.ID, + Poster: user, + IsPull: true, + } + + pullRequest := &issues_model.PullRequest{ + HeadRepoID: repo.ID, + BaseRepoID: repo.ID, + HeadBranch: "branch", + BaseBranch: "main", + HeadRepo: repo, + BaseRepo: repo, + Type: issues_model.PullRequestGitea, + } + + err = pull_service.NewPullRequest(git.DefaultContext, repo, pullIssue, nil, nil, pullRequest, nil) + require.NoError(t, err) + + html := extractHTML(t, session, issue1, "div.timeline > div:nth-child(4) > div.detail > * > a") + assertContainsAll(t, titleHTML, html) + }) + }) +} + +func createIssue(t *testing.T, user *user_model.User, repo *repo_model.Repository, title, content string) *issues_model.Issue { + issue := &issues_model.Issue{ + RepoID: repo.ID, + Title: title, + Content: content, + PosterID: user.ID, + Poster: user, + } + + err := issue_service.NewIssue(db.DefaultContext, repo, issue, nil, nil, nil) + require.NoError(t, err) + + return issue +} + +func extractHTML(t *testing.T, session *TestSession, issue *issues_model.Issue, query string) string { + req := NewRequest(t, "GET", issue.HTMLURL()) + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + res, err := doc.doc.Find(query).Html() + require.NoError(t, err) + + return res +} + +func assertContainsAll(t *testing.T, expected []string, actual string) { + for i := range expected { + assert.Contains(t, actual, expected[i]) + } +} diff --git a/tests/integration/repo_mergecommit_revert_test.go b/tests/integration/repo_mergecommit_revert_test.go new file mode 100644 index 0000000..eb75d45 --- /dev/null +++ b/tests/integration/repo_mergecommit_revert_test.go @@ -0,0 +1,38 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestRepoMergeCommitRevert(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user2/test_commit_revert/_cherrypick/deebcbc752e540bab4ce3ee713d3fc8fdc35b2f7/main?ref=main&refType=branch&cherry-pick-type=revert") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + req = NewRequestWithValues(t, "POST", "/user2/test_commit_revert/_cherrypick/deebcbc752e540bab4ce3ee713d3fc8fdc35b2f7/main", map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "last_commit": "deebcbc752e540bab4ce3ee713d3fc8fdc35b2f7", + "page_has_posted": "true", + "revert": "true", + "commit_summary": "reverting test commit", + "commit_message": "test message", + "commit_choice": "direct", + "new_branch_name": "test-revert-branch-1", + "commit_mail_id": "-1", + }) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + + // A successful revert redirects to the main branch + assert.EqualValues(t, "/user2/test_commit_revert/src/branch/main", resp.Header().Get("Location")) +} diff --git a/tests/integration/repo_migrate_test.go b/tests/integration/repo_migrate_test.go new file mode 100644 index 0000000..9fb7a73 --- /dev/null +++ b/tests/integration/repo_migrate_test.go @@ -0,0 +1,57 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func testRepoMigrate(t testing.TB, session *TestSession, cloneAddr, repoName string, service structs.GitServiceType) *httptest.ResponseRecorder { + req := NewRequest(t, "GET", fmt.Sprintf("/repo/migrate?service_type=%d", service)) // render plain git migration page + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action") + assert.True(t, exists, "The template has changed") + + uid, exists := htmlDoc.doc.Find("#uid").Attr("value") + assert.True(t, exists, "The template has changed") + + req = NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "clone_addr": cloneAddr, + "uid": uid, + "repo_name": repoName, + "service": fmt.Sprintf("%d", service), + }) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + + return resp +} + +func TestRepoMigrate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + for _, s := range []struct { + testName string + cloneAddr string + repoName string + service structs.GitServiceType + }{ + {"TestMigrateGithub", "https://github.com/go-gitea/test_repo.git", "git", structs.PlainGitService}, + {"TestMigrateGithub", "https://github.com/go-gitea/test_repo.git", "github", structs.GithubService}, + } { + t.Run(s.testName, func(t *testing.T) { + testRepoMigrate(t, session, s.cloneAddr, s.repoName, s.service) + }) + } +} diff --git a/tests/integration/repo_migration_ui_test.go b/tests/integration/repo_migration_ui_test.go new file mode 100644 index 0000000..40688d4 --- /dev/null +++ b/tests/integration/repo_migration_ui_test.go @@ -0,0 +1,116 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "testing" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" +) + +func TestRepoMigrationUI(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + sessionUser1 := loginUser(t, "user1") + // Nothing is tested in plain Git migration form right now + testRepoMigrationFormGitHub(t, sessionUser1) + testRepoMigrationFormGitea(t, sessionUser1) + testRepoMigrationFormGitLab(t, sessionUser1) + testRepoMigrationFormGogs(t, sessionUser1) + testRepoMigrationFormOneDev(t, sessionUser1) + testRepoMigrationFormGitBucket(t, sessionUser1) + testRepoMigrationFormCodebase(t, sessionUser1) + testRepoMigrationFormForgejo(t, sessionUser1) + }) +} + +func testRepoMigrationFormGitHub(t *testing.T, session *TestSession) { + response := session.MakeRequest(t, NewRequest(t, "GET", "/repo/migrate?service_type=2"), http.StatusOK) + page := NewHTMLParser(t, response.Body) + + items := page.Find("#migrate_items .field .checkbox input") + expectedItems := []string{"issues", "pull_requests", "labels", "milestones", "releases"} + testRepoMigrationFormItems(t, items, expectedItems) +} + +func testRepoMigrationFormGitea(t *testing.T, session *TestSession) { + response := session.MakeRequest(t, NewRequest(t, "GET", "/repo/migrate?service_type=3"), http.StatusOK) + page := NewHTMLParser(t, response.Body) + + items := page.Find("#migrate_items .field .checkbox input") + expectedItems := []string{"issues", "pull_requests", "labels", "milestones", "releases"} + testRepoMigrationFormItems(t, items, expectedItems) +} + +func testRepoMigrationFormGitLab(t *testing.T, session *TestSession) { + response := session.MakeRequest(t, NewRequest(t, "GET", "/repo/migrate?service_type=4"), http.StatusOK) + page := NewHTMLParser(t, response.Body) + + items := page.Find("#migrate_items .field .checkbox input") + // Note: the checkbox "Merge requests" has name "pull_requests" + expectedItems := []string{"issues", "pull_requests", "labels", "milestones", "releases"} + testRepoMigrationFormItems(t, items, expectedItems) +} + +func testRepoMigrationFormGogs(t *testing.T, session *TestSession) { + response := session.MakeRequest(t, NewRequest(t, "GET", "/repo/migrate?service_type=5"), http.StatusOK) + page := NewHTMLParser(t, response.Body) + + items := page.Find("#migrate_items .field .checkbox input") + expectedItems := []string{"issues", "labels", "milestones"} + testRepoMigrationFormItems(t, items, expectedItems) +} + +func testRepoMigrationFormOneDev(t *testing.T, session *TestSession) { + response := session.MakeRequest(t, NewRequest(t, "GET", "/repo/migrate?service_type=6"), http.StatusOK) + page := NewHTMLParser(t, response.Body) + + items := page.Find("#migrate_items .field .checkbox input") + expectedItems := []string{"issues", "pull_requests", "labels", "milestones"} + testRepoMigrationFormItems(t, items, expectedItems) +} + +func testRepoMigrationFormGitBucket(t *testing.T, session *TestSession) { + response := session.MakeRequest(t, NewRequest(t, "GET", "/repo/migrate?service_type=7"), http.StatusOK) + page := NewHTMLParser(t, response.Body) + + items := page.Find("#migrate_items .field .checkbox input") + expectedItems := []string{"issues", "pull_requests", "labels", "milestones", "releases"} + testRepoMigrationFormItems(t, items, expectedItems) +} + +func testRepoMigrationFormCodebase(t *testing.T, session *TestSession) { + response := session.MakeRequest(t, NewRequest(t, "GET", "/repo/migrate?service_type=8"), http.StatusOK) + page := NewHTMLParser(t, response.Body) + + items := page.Find("#migrate_items .field .checkbox input") + // Note: the checkbox "Merge requests" has name "pull_requests" + expectedItems := []string{"issues", "pull_requests", "labels", "milestones"} + testRepoMigrationFormItems(t, items, expectedItems) +} + +func testRepoMigrationFormForgejo(t *testing.T, session *TestSession) { + response := session.MakeRequest(t, NewRequest(t, "GET", "/repo/migrate?service_type=9"), http.StatusOK) + page := NewHTMLParser(t, response.Body) + + items := page.Find("#migrate_items .field .checkbox input") + expectedItems := []string{"issues", "pull_requests", "labels", "milestones", "releases"} + testRepoMigrationFormItems(t, items, expectedItems) +} + +func testRepoMigrationFormItems(t *testing.T, items *goquery.Selection, expectedItems []string) { + t.Helper() + + // Compare lengths of item lists + assert.EqualValues(t, len(expectedItems), items.Length()) + + // Compare contents of item lists + for index, expectedName := range expectedItems { + name, exists := items.Eq(index).Attr("name") + assert.True(t, exists) + assert.EqualValues(t, expectedName, name) + } +} diff --git a/tests/integration/repo_pagination_test.go b/tests/integration/repo_pagination_test.go new file mode 100644 index 0000000..1c1f2ac --- /dev/null +++ b/tests/integration/repo_pagination_test.go @@ -0,0 +1,84 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "path" + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestRepoPaginations(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("Fork", func(t *testing.T) { + // Make forks of user2/repo1 + session := loginUser(t, "user2") + testRepoFork(t, session, "user2", "repo1", "org3", "repo1") + session = loginUser(t, "user5") + testRepoFork(t, session, "user2", "repo1", "org6", "repo1") + + unittest.AssertCount(t, &repo_model.Repository{ForkID: 1}, 2) + + testRepoPagination(t, session, "user2/repo1", "forks", &setting.MaxForksPerPage) + }) + t.Run("Stars", func(t *testing.T) { + // Add stars to user2/repo1. + session := loginUser(t, "user2") + req := NewRequestWithValues(t, "POST", "/user2/repo1/action/star", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1"), + }) + session.MakeRequest(t, req, http.StatusOK) + + session = loginUser(t, "user1") + req = NewRequestWithValues(t, "POST", "/user2/repo1/action/star", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1"), + }) + session.MakeRequest(t, req, http.StatusOK) + + testRepoPagination(t, session, "user2/repo1", "stars", &setting.MaxUserCardsPerPage) + }) + t.Run("Watcher", func(t *testing.T) { + // user2/repo2 is watched by its creator user2. Watch it by user1 to make it watched by 2 users. + session := loginUser(t, "user1") + req := NewRequestWithValues(t, "POST", "/user2/repo2/action/watch", map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo2"), + }) + session.MakeRequest(t, req, http.StatusOK) + + testRepoPagination(t, session, "user2/repo2", "watchers", &setting.MaxUserCardsPerPage) + }) +} + +func testRepoPagination(t *testing.T, session *TestSession, repo, kind string, mockableVar *int) { + t.Run("Should paginate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(mockableVar, 1)() + req := NewRequest(t, "GET", "/"+path.Join(repo, kind)) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + paginationButton := htmlDoc.Find(".item.navigation[href='/" + path.Join(repo, kind) + "?page=2']") + // Next and Last button. + assert.Equal(t, 2, paginationButton.Length()) + }) + + t.Run("Shouldn't paginate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(mockableVar, 2)() + req := NewRequest(t, "GET", "/"+path.Join(repo, kind)) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, ".item.navigation[href='/"+path.Join(repo, kind)+"?page=2']", false) + }) +} diff --git a/tests/integration/repo_search_test.go b/tests/integration/repo_search_test.go new file mode 100644 index 0000000..c7a31f4 --- /dev/null +++ b/tests/integration/repo_search_test.go @@ -0,0 +1,135 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + code_indexer "code.gitea.io/gitea/modules/indexer/code" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func resultFilenames(t testing.TB, doc *HTMLDoc) []string { + resultSelections := doc. + Find(".repository.search"). + Find("details.repo-search-result") + + result := make([]string, resultSelections.Length()) + resultSelections.Each(func(i int, selection *goquery.Selection) { + assert.Positive(t, selection.Find("div ol li").Length(), 0) + assert.Positive(t, selection.Find(".code-inner").Find(".search-highlight").Length(), 0) + result[i] = selection. + Find(".header"). + Find("span.file a.file-link"). + First(). + Text() + }) + + return result +} + +func TestSearchRepoIndexer(t *testing.T) { + testSearchRepo(t, true) +} + +func TestSearchRepoNoIndexer(t *testing.T) { + testSearchRepo(t, false) +} + +func testSearchRepo(t *testing.T, indexer bool) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Indexer.RepoIndexerEnabled, indexer)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "repo1") + require.NoError(t, err) + + if indexer { + code_indexer.UpdateRepoIndexer(repo) + } + + testSearch(t, "/user2/repo1/search?q=Description&page=1", []string{"README.md"}, indexer) + + req := NewRequest(t, "HEAD", "/user2/repo1/search/branch/"+repo.DefaultBranch) + if indexer { + MakeRequest(t, req, http.StatusNotFound) + } else { + MakeRequest(t, req, http.StatusOK) + } + + defer test.MockVariableValue(&setting.Indexer.IncludePatterns, setting.IndexerGlobFromString("**.txt"))() + defer test.MockVariableValue(&setting.Indexer.ExcludePatterns, setting.IndexerGlobFromString("**/y/**"))() + + repo, err = repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "glob") + require.NoError(t, err) + + if indexer { + code_indexer.UpdateRepoIndexer(repo) + } + + testSearch(t, "/user2/glob/search?q=loren&page=1", []string{"a.txt"}, indexer) + testSearch(t, "/user2/glob/search?q=loren&page=1&fuzzy=false", []string{"a.txt"}, indexer) + + if indexer { + // fuzzy search: matches both file3 (x/b.txt) and file1 (a.txt) + // when indexer is enabled + testSearch(t, "/user2/glob/search?q=file3&page=1", []string{"x/b.txt", "a.txt"}, indexer) + testSearch(t, "/user2/glob/search?q=file4&page=1", []string{"x/b.txt", "a.txt"}, indexer) + testSearch(t, "/user2/glob/search?q=file5&page=1", []string{"x/b.txt", "a.txt"}, indexer) + } else { + // fuzzy search: Union/OR of all the keywords + // when indexer is disabled + testSearch(t, "/user2/glob/search?q=file3+file1&page=1", []string{"a.txt", "x/b.txt"}, indexer) + testSearch(t, "/user2/glob/search?q=file4&page=1", []string{}, indexer) + testSearch(t, "/user2/glob/search?q=file5&page=1", []string{}, indexer) + } + + testSearch(t, "/user2/glob/search?q=file3&page=1&fuzzy=false", []string{"x/b.txt"}, indexer) + testSearch(t, "/user2/glob/search?q=file4&page=1&fuzzy=false", []string{}, indexer) + testSearch(t, "/user2/glob/search?q=file5&page=1&fuzzy=false", []string{}, indexer) +} + +func testSearch(t *testing.T, url string, expected []string, indexer bool) { + req := NewRequest(t, "GET", url) + resp := MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + container := doc.Find(".repository").Find(".ui.container") + + grepMsg := container.Find(".ui.message[data-test-tag=grep]") + assert.EqualValues(t, indexer, len(grepMsg.Nodes) == 0) + + branchDropdown := container.Find(".js-branch-tag-selector") + assert.EqualValues(t, indexer, len(branchDropdown.Nodes) == 0) + + // if indexer is disabled "fuzzy" should be displayed as "union" + expectedFuzzy := "Fuzzy" + if !indexer { + expectedFuzzy = "Union" + } + + fuzzyDropdown := container.Find(".ui.dropdown[data-test-tag=fuzzy-dropdown]") + actualFuzzyText := fuzzyDropdown.Find(".menu .item[data-value=true]").First().Text() + assert.EqualValues(t, expectedFuzzy, actualFuzzyText) + + if fuzzyDropdown. + Find("input[name=fuzzy][value=true]"). + Length() != 0 { + actualFuzzyText = fuzzyDropdown.Find("div.text").First().Text() + assert.EqualValues(t, expectedFuzzy, actualFuzzyText) + } + + filenames := resultFilenames(t, doc) + assert.EqualValues(t, expected, filenames) +} diff --git a/tests/integration/repo_settings_hook_test.go b/tests/integration/repo_settings_hook_test.go new file mode 100644 index 0000000..0a3dd57 --- /dev/null +++ b/tests/integration/repo_settings_hook_test.go @@ -0,0 +1,63 @@ +// Copyright 2022 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "strings" + "testing" + + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepoSettingsHookHistory(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + // Request repository hook page with history + req := NewRequest(t, "GET", "/user2/repo1/settings/hooks/1") + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + + t.Run("1/delivered", func(t *testing.T) { + html, err := doc.doc.Find(".webhook div[data-tab='request-1']").Html() + require.NoError(t, err) + assert.Contains(t, html, "<strong>Request URL:</strong> /matrix-delivered\n") + assert.Contains(t, html, "<strong>Request method:</strong> PUT") + assert.Contains(t, html, "<strong>X-Head:</strong> 42") + assert.Contains(t, html, `<code class="json">{}</code>`) + + val, ok := doc.doc.Find(".webhook div.item:has(div#info-1) svg").Attr("class") + assert.True(t, ok) + assert.Equal(t, "svg octicon-alert", val) + }) + + t.Run("2/undelivered", func(t *testing.T) { + html, err := doc.doc.Find(".webhook div[data-tab='request-2']").Html() + require.NoError(t, err) + assert.Equal(t, "-", strings.TrimSpace(html)) + + val, ok := doc.doc.Find(".webhook div.item:has(div#info-2) svg").Attr("class") + assert.True(t, ok) + assert.Equal(t, "svg octicon-stopwatch", val) + }) + + t.Run("3/success", func(t *testing.T) { + html, err := doc.doc.Find(".webhook div[data-tab='request-3']").Html() + require.NoError(t, err) + assert.Contains(t, html, "<strong>Request URL:</strong> /matrix-success\n") + assert.Contains(t, html, "<strong>Request method:</strong> PUT") + assert.Contains(t, html, "<strong>X-Head:</strong> 42") + assert.Contains(t, html, `<code class="json">{"key":"value"}</code>`) + + val, ok := doc.doc.Find(".webhook div.item:has(div#info-3) svg").Attr("class") + assert.True(t, ok) + assert.Equal(t, "svg octicon-check", val) + }) +} diff --git a/tests/integration/repo_settings_test.go b/tests/integration/repo_settings_test.go new file mode 100644 index 0000000..ff13853 --- /dev/null +++ b/tests/integration/repo_settings_test.go @@ -0,0 +1,370 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/forgefed" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + fm "code.gitea.io/gitea/modules/forgefed" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/validation" + gitea_context "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" + user_service "code.gitea.io/gitea/services/user" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepoSettingsUnits(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user.ID, Name: "repo1"}) + session := loginUser(t, user.Name) + + req := NewRequest(t, "GET", fmt.Sprintf("%s/settings/units", repo.Link())) + session.MakeRequest(t, req, http.StatusOK) +} + +func TestRepoAddMoreUnitsHighlighting(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}) + session := loginUser(t, user.Name) + + // Make sure there are no disabled repos in the settings! + setting.Repository.DisabledRepoUnits = []string{} + unit_model.LoadUnitConfig() + + // Create a known-good repo, with some units disabled. + repo, _, f := tests.CreateDeclarativeRepo(t, user, "", []unit_model.Type{ + unit_model.TypeCode, + unit_model.TypePullRequests, + unit_model.TypeProjects, + unit_model.TypeActions, + unit_model.TypeIssues, + unit_model.TypeWiki, + }, []unit_model.Type{unit_model.TypePackages}, nil) + defer f() + + setUserHints := func(t *testing.T, hints bool) func() { + saved := user.EnableRepoUnitHints + + require.NoError(t, user_service.UpdateUser(db.DefaultContext, user, &user_service.UpdateOptions{ + EnableRepoUnitHints: optional.Some(hints), + })) + + return func() { + require.NoError(t, user_service.UpdateUser(db.DefaultContext, user, &user_service.UpdateOptions{ + EnableRepoUnitHints: optional.Some(saved), + })) + } + } + + assertHighlight := func(t *testing.T, page, uri string, highlighted bool) { + t.Helper() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/settings%s", repo.Link(), page)) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, fmt.Sprintf(".overflow-menu-items a[href='%s'].active", fmt.Sprintf("%s/settings%s", repo.Link(), uri)), highlighted) + } + + t.Run("hints enabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer setUserHints(t, true)() + + t.Run("settings", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Visiting the /settings page, "Settings" is highlighted + assertHighlight(t, "", "", true) + // ...but "Add more" isn't. + assertHighlight(t, "", "/units", false) + }) + + t.Run("units", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Visiting the /settings/units page, "Add more" is highlighted + assertHighlight(t, "/units", "/units", true) + // ...but "Settings" isn't. + assertHighlight(t, "/units", "", false) + }) + }) + + t.Run("hints disabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer setUserHints(t, false)() + + t.Run("settings", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Visiting the /settings page, "Settings" is highlighted + assertHighlight(t, "", "", true) + // ...but "Add more" isn't (it doesn't exist). + assertHighlight(t, "", "/units", false) + }) + + t.Run("units", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Visiting the /settings/units page, "Settings" is highlighted + assertHighlight(t, "/units", "", true) + // ...but "Add more" isn't (it doesn't exist) + assertHighlight(t, "/units", "/units", false) + }) + }) +} + +func TestRepoAddMoreUnits(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}) + session := loginUser(t, user.Name) + + // Make sure there are no disabled repos in the settings! + setting.Repository.DisabledRepoUnits = []string{} + unit_model.LoadUnitConfig() + + // Create a known-good repo, with all units enabled. + repo, _, f := tests.CreateDeclarativeRepo(t, user, "", []unit_model.Type{ + unit_model.TypeCode, + unit_model.TypePullRequests, + unit_model.TypeProjects, + unit_model.TypePackages, + unit_model.TypeActions, + unit_model.TypeIssues, + unit_model.TypeWiki, + }, nil, nil) + defer f() + + assertAddMore := func(t *testing.T, present bool) { + t.Helper() + + req := NewRequest(t, "GET", repo.Link()) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, fmt.Sprintf("a[href='%s/settings/units']", repo.Link()), present) + } + + t.Run("no add more with all units enabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assertAddMore(t, false) + }) + + t.Run("add more if units can be enabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer func() { + repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, []repo_model.RepoUnit{{ + RepoID: repo.ID, + Type: unit_model.TypePackages, + }}, nil) + }() + + // Disable the Packages unit + err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, nil, []unit_model.Type{unit_model.TypePackages}) + require.NoError(t, err) + + assertAddMore(t, true) + }) + + t.Run("no add more if unit is globally disabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer func() { + repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, []repo_model.RepoUnit{{ + RepoID: repo.ID, + Type: unit_model.TypePackages, + }}, nil) + setting.Repository.DisabledRepoUnits = []string{} + unit_model.LoadUnitConfig() + }() + + // Disable the Packages unit globally + setting.Repository.DisabledRepoUnits = []string{"repo.packages"} + unit_model.LoadUnitConfig() + + // Disable the Packages unit + err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, nil, []unit_model.Type{unit_model.TypePackages}) + require.NoError(t, err) + + // The "Add more" link appears no more + assertAddMore(t, false) + }) + + t.Run("issues & ext tracker globally disabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer func() { + repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, []repo_model.RepoUnit{{ + RepoID: repo.ID, + Type: unit_model.TypeIssues, + }}, nil) + setting.Repository.DisabledRepoUnits = []string{} + unit_model.LoadUnitConfig() + }() + + // Disable both Issues and ExternalTracker units globally + setting.Repository.DisabledRepoUnits = []string{"repo.issues", "repo.ext_issues"} + unit_model.LoadUnitConfig() + + // Disable the Issues unit + err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, nil, []unit_model.Type{unit_model.TypeIssues}) + require.NoError(t, err) + + // The "Add more" link appears no more + assertAddMore(t, false) + }) +} + +func TestProtectedBranch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user.ID}) + session := loginUser(t, user.Name) + + t.Run("Add", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + link := fmt.Sprintf("/%s/settings/branches/edit", repo.FullName()) + + req := NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": GetCSRF(t, session, link), + "rule_name": "master", + "enable_push": "true", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + // Verify it was added. + unittest.AssertExistsIf(t, true, &git_model.ProtectedBranch{RuleName: "master", RepoID: repo.ID}) + }) + + t.Run("Add duplicate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + link := fmt.Sprintf("/%s/settings/branches/edit", repo.FullName()) + + req := NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": GetCSRF(t, session, link), + "rule_name": "master", + "require_signed_": "true", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + flashCookie := session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "error%3DThere%2Bis%2Balready%2Ba%2Brule%2Bfor%2Bthis%2Bset%2Bof%2Bbranches", flashCookie.Value) + + // Verify it wasn't added. + unittest.AssertCount(t, &git_model.ProtectedBranch{RuleName: "master", RepoID: repo.ID}, 1) + }) +} + +func TestRepoFollowing(t *testing.T) { + setting.Federation.Enabled = true + defer tests.PrepareTestEnv(t)() + defer func() { + setting.Federation.Enabled = false + }() + + federatedRoutes := http.NewServeMux() + federatedRoutes.HandleFunc("/.well-known/nodeinfo", + func(res http.ResponseWriter, req *http.Request) { + // curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/.well-known/nodeinfo + responseBody := fmt.Sprintf(`{"links":[{"href":"http://%s/api/v1/nodeinfo","rel":"http://nodeinfo.diaspora.software/ns/schema/2.1"}]}`, req.Host) + t.Logf("response: %s", responseBody) + // TODO: as soon as content-type will become important: content-type: application/json;charset=utf-8 + fmt.Fprint(res, responseBody) + }) + federatedRoutes.HandleFunc("/api/v1/nodeinfo", + func(res http.ResponseWriter, req *http.Request) { + // curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/nodeinfo + responseBody := fmt.Sprintf(`{"version":"2.1","software":{"name":"forgejo","version":"1.20.0+dev-3183-g976d79044",` + + `"repository":"https://codeberg.org/forgejo/forgejo.git","homepage":"https://forgejo.org/"},` + + `"protocols":["activitypub"],"services":{"inbound":[],"outbound":["rss2.0"]},` + + `"openRegistrations":true,"usage":{"users":{"total":14,"activeHalfyear":2}},"metadata":{}}`) + fmt.Fprint(res, responseBody) + }) + repo1InboxReceivedLike := false + federatedRoutes.HandleFunc("/api/v1/activitypub/repository-id/1/inbox/", + func(res http.ResponseWriter, req *http.Request) { + if req.Method != "POST" { + t.Errorf("Unhandled request: %q", req.URL.EscapedPath()) + } + buf := new(strings.Builder) + _, err := io.Copy(buf, req.Body) + if err != nil { + t.Errorf("Error reading body: %q", err) + } + like := fm.ForgeLike{} + err = like.UnmarshalJSON([]byte(buf.String())) + if err != nil { + t.Errorf("Error unmarshalling ForgeLike: %q", err) + } + if isValid, err := validation.IsValid(like); !isValid { + t.Errorf("ForgeLike is not valid: %q", err) + } + + activityType := like.Type + object := like.Object.GetLink().String() + isLikeType := activityType == "Like" + isCorrectObject := strings.HasSuffix(object, "/api/v1/activitypub/repository-id/1") + if !isLikeType || !isCorrectObject { + t.Errorf("Activity is not a like for this repo") + } + + repo1InboxReceivedLike = true + }) + federatedRoutes.HandleFunc("/", + func(res http.ResponseWriter, req *http.Request) { + t.Errorf("Unhandled request: %q", req.URL.EscapedPath()) + }) + federatedSrv := httptest.NewServer(federatedRoutes) + defer federatedSrv.Close() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user.ID}) + session := loginUser(t, user.Name) + + t.Run("Add a following repo", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + link := fmt.Sprintf("/%s/settings", repo.FullName()) + + req := NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": GetCSRF(t, session, link), + "action": "federation", + "following_repos": fmt.Sprintf("%s/api/v1/activitypub/repository-id/1", federatedSrv.URL), + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + // Verify it was added. + federationHost := unittest.AssertExistsAndLoadBean(t, &forgefed.FederationHost{HostFqdn: "127.0.0.1"}) + unittest.AssertExistsAndLoadBean(t, &repo_model.FollowingRepo{ + ExternalID: "1", + FederationHostID: federationHost.ID, + }) + }) + + t.Run("Star a repo having a following repo", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + repoLink := fmt.Sprintf("/%s", repo.FullName()) + link := fmt.Sprintf("%s/action/star", repoLink) + req := NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": GetCSRF(t, session, repoLink), + }) + assert.False(t, repo1InboxReceivedLike) + session.MakeRequest(t, req, http.StatusOK) + assert.True(t, repo1InboxReceivedLike) + }) +} diff --git a/tests/integration/repo_signed_tag_test.go b/tests/integration/repo_signed_tag_test.go new file mode 100644 index 0000000..41e663c --- /dev/null +++ b/tests/integration/repo_signed_tag_test.go @@ -0,0 +1,107 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "os" + "os/exec" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/graceful" + repo_module "code.gitea.io/gitea/modules/repository" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/require" +) + +func TestRepoSSHSignedTags(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // Preparations + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo, _, f := tests.CreateDeclarativeRepo(t, user, "", nil, nil, nil) + defer f() + + // Set up an SSH key for the tagger + tmpDir := t.TempDir() + err := os.Chmod(tmpDir, 0o700) + require.NoError(t, err) + + signingKey := fmt.Sprintf("%s/ssh_key", tmpDir) + + cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-N", "", "-f", signingKey) + err = cmd.Run() + require.NoError(t, err) + + // Set up git config for the tagger + _ = git.NewCommand(git.DefaultContext, "config", "user.name").AddDynamicArguments(user.Name).Run(&git.RunOpts{Dir: repo.RepoPath()}) + _ = git.NewCommand(git.DefaultContext, "config", "user.email").AddDynamicArguments(user.Email).Run(&git.RunOpts{Dir: repo.RepoPath()}) + _ = git.NewCommand(git.DefaultContext, "config", "gpg.format", "ssh").Run(&git.RunOpts{Dir: repo.RepoPath()}) + _ = git.NewCommand(git.DefaultContext, "config", "user.signingkey").AddDynamicArguments(signingKey).Run(&git.RunOpts{Dir: repo.RepoPath()}) + + // Open the git repo + gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo) + defer gitRepo.Close() + + // Create a signed tag + err = git.NewCommand(git.DefaultContext, "tag", "-s", "-m", "this is a signed tag", "ssh-signed-tag").Run(&git.RunOpts{Dir: repo.RepoPath()}) + require.NoError(t, err) + + // Sync the tag to the DB + repo_module.SyncRepoTags(graceful.GetManager().ShutdownContext(), repo.ID) + + // Helper functions + assertTagSignedStatus := func(t *testing.T, isSigned bool) { + t.Helper() + + req := NewRequestf(t, "GET", "%s/releases/tag/ssh-signed-tag", repo.HTMLURL()) + resp := MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + doc.AssertElement(t, ".tag-signature-row .gitea-unlock", !isSigned) + doc.AssertElement(t, ".tag-signature-row .gitea-lock", isSigned) + } + + t.Run("unverified", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assertTagSignedStatus(t, false) + }) + + t.Run("verified", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Upload the signing key + keyData, err := os.ReadFile(fmt.Sprintf("%s.pub", signingKey)) + require.NoError(t, err) + key := string(keyData) + + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + req := NewRequestWithJSON(t, "POST", "/api/v1/user/keys", &api.CreateKeyOption{ + Key: key, + Title: "test key", + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var pubkey *api.PublicKey + DecodeJSON(t, resp, &pubkey) + + // Mark the key as verified + db.GetEngine(db.DefaultContext).Exec("UPDATE `public_key` SET verified = true WHERE id = ?", pubkey.ID) + + // Check the tag page + assertTagSignedStatus(t, true) + }) +} diff --git a/tests/integration/repo_starwatch_test.go b/tests/integration/repo_starwatch_test.go new file mode 100644 index 0000000..a8bad30 --- /dev/null +++ b/tests/integration/repo_starwatch_test.go @@ -0,0 +1,108 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func testRepoStarringOrWatching(t *testing.T, action, listURI string) { + t.Helper() + + defer tests.PrepareTestEnv(t)() + + oppositeAction := "un" + action + session := loginUser(t, "user5") + + // Star/Watch the repo as user5 + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/user2/repo1/action/%s", action), map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1"), + }) + session.MakeRequest(t, req, http.StatusOK) + + // Load the repo home as user5 + req = NewRequest(t, "GET", "/user2/repo1") + resp := session.MakeRequest(t, req, http.StatusOK) + + // Verify that the star/watch button is now the opposite + htmlDoc := NewHTMLParser(t, resp.Body) + actionButton := htmlDoc.Find(fmt.Sprintf("form[action='/user2/repo1/action/%s']", oppositeAction)) + assert.Equal(t, 1, actionButton.Length()) + text := strings.ToLower(actionButton.Find("button span.text").Text()) + assert.Equal(t, oppositeAction, text) + + // Load stargazers/watchers as user5 + req = NewRequestf(t, "GET", "/user2/repo1/%s", listURI) + resp = session.MakeRequest(t, req, http.StatusOK) + + // Verify that "user5" is among the stargazers/watchers + htmlDoc = NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, ".user-cards .list .card > a[href='/user5']", true) + + // Unstar/unwatch the repo as user5 + req = NewRequestWithValues(t, "POST", fmt.Sprintf("/user2/repo1/action/%s", oppositeAction), map[string]string{ + "_csrf": GetCSRF(t, session, "/user2/repo1"), + }) + session.MakeRequest(t, req, http.StatusOK) + + // Load the repo home as user5 + req = NewRequest(t, "GET", "/user2/repo1") + resp = session.MakeRequest(t, req, http.StatusOK) + + // Verify that the star/watch button is now back to its default + htmlDoc = NewHTMLParser(t, resp.Body) + actionButton = htmlDoc.Find(fmt.Sprintf("form[action='/user2/repo1/action/%s']", action)) + assert.Equal(t, 1, actionButton.Length()) + text = strings.ToLower(actionButton.Find("button span.text").Text()) + assert.Equal(t, action, text) + + // Load stargazers/watchers as user5 + req = NewRequestf(t, "GET", "/user2/repo1/%s", listURI) + resp = session.MakeRequest(t, req, http.StatusOK) + + // Verify that "user5" is not among the stargazers/watchers + htmlDoc = NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, ".user-cards .list .item.ui.segment > a[href='/user5']", false) +} + +func TestRepoStarUnstarUI(t *testing.T) { + testRepoStarringOrWatching(t, "star", "stars") +} + +func TestRepoWatchUnwatchUI(t *testing.T) { + testRepoStarringOrWatching(t, "watch", "watchers") +} + +func TestDisabledStars(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Repository.DisableStars, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + t.Run("repo star, unstar", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "POST", "/user2/repo1/action/star") + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "POST", "/user2/repo1/action/unstar") + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("repo stargazers", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/stars") + MakeRequest(t, req, http.StatusNotFound) + }) +} diff --git a/tests/integration/repo_tag_test.go b/tests/integration/repo_tag_test.go new file mode 100644 index 0000000..d5539cb --- /dev/null +++ b/tests/integration/repo_tag_test.go @@ -0,0 +1,165 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "strings" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/services/release" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTagViewWithoutRelease(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + defer func() { + releases, err := db.Find[repo_model.Release](db.DefaultContext, repo_model.FindReleasesOptions{ + IncludeTags: true, + TagNames: []string{"no-release"}, + RepoID: repo.ID, + }) + require.NoError(t, err) + + for _, release := range releases { + _, err = db.DeleteByID[repo_model.Release](db.DefaultContext, release.ID) + require.NoError(t, err) + } + }() + + err := release.CreateNewTag(git.DefaultContext, owner, repo, "master", "no-release", "release-less tag") + require.NoError(t, err) + + // Test that the page loads + req := NewRequestf(t, "GET", "/%s/releases/tag/no-release", repo.FullName()) + resp := MakeRequest(t, req, http.StatusOK) + + // Test that the tags sub-menu is active and has a counter + htmlDoc := NewHTMLParser(t, resp.Body) + tagsTab := htmlDoc.Find(".small-menu-items .active.item[href$='/tags']") + assert.Contains(t, tagsTab.Text(), "4 tags") + + // Test that the release sub-menu isn't active + releaseLink := htmlDoc.Find(".small-menu-items .item[href$='/releases']") + assert.False(t, releaseLink.HasClass("active")) + + // Test that the title is displayed + releaseTitle := strings.TrimSpace(htmlDoc.Find("h4.release-list-title > a").Text()) + assert.Equal(t, "no-release", releaseTitle) + + // Test that there is no "Stable" link + htmlDoc.AssertElement(t, "h4.release-list-title > span.ui.green.label", false) +} + +func TestCreateNewTagProtected(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + t.Run("Code", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + err := release.CreateNewTag(git.DefaultContext, owner, repo, "master", "t-first", "first tag") + require.NoError(t, err) + + err = release.CreateNewTag(git.DefaultContext, owner, repo, "master", "v-2", "second tag") + require.Error(t, err) + assert.True(t, models.IsErrProtectedTagName(err)) + + err = release.CreateNewTag(git.DefaultContext, owner, repo, "master", "v-1.1", "third tag") + require.NoError(t, err) + }) + + t.Run("Git", func(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + httpContext := NewAPITestContext(t, owner.Name, repo.Name) + + dstPath := t.TempDir() + + u.Path = httpContext.GitPath() + u.User = url.UserPassword(owner.Name, userPassword) + + doGitClone(dstPath, u)(t) + + _, _, err := git.NewCommand(git.DefaultContext, "tag", "v-2").RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + _, _, err = git.NewCommand(git.DefaultContext, "push", "--tags").RunStdString(&git.RunOpts{Dir: dstPath}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Tag v-2 is protected") + }) + }) + + t.Run("GitTagForce", func(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + httpContext := NewAPITestContext(t, owner.Name, repo.Name) + + dstPath := t.TempDir() + + u.Path = httpContext.GitPath() + u.User = url.UserPassword(owner.Name, userPassword) + + doGitClone(dstPath, u)(t) + + _, _, err := git.NewCommand(git.DefaultContext, "tag", "v-1.1", "-m", "force update", "--force").RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + _, _, err = git.NewCommand(git.DefaultContext, "push", "--tags").RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + _, _, err = git.NewCommand(git.DefaultContext, "tag", "v-1.1", "-m", "force update v2", "--force").RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + _, _, err = git.NewCommand(git.DefaultContext, "push", "--tags").RunStdString(&git.RunOpts{Dir: dstPath}) + require.Error(t, err) + assert.Contains(t, err.Error(), "the tag already exists in the remote") + + _, _, err = git.NewCommand(git.DefaultContext, "push", "--tags", "--force").RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + req := NewRequestf(t, "GET", "/%s/releases/tag/v-1.1", repo.FullName()) + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + tagsTab := htmlDoc.Find(".release-list-title") + assert.Contains(t, tagsTab.Text(), "force update v2") + }) + }) + + // Cleanup + releases, err := db.Find[repo_model.Release](db.DefaultContext, repo_model.FindReleasesOptions{ + IncludeTags: true, + TagNames: []string{"v-1", "v-1.1"}, + RepoID: repo.ID, + }) + require.NoError(t, err) + + for _, release := range releases { + _, err = db.DeleteByID[repo_model.Release](db.DefaultContext, release.ID) + require.NoError(t, err) + } + + protectedTags, err := git_model.GetProtectedTags(db.DefaultContext, repo.ID) + require.NoError(t, err) + + for _, protectedTag := range protectedTags { + err = git_model.DeleteProtectedTag(db.DefaultContext, protectedTag) + require.NoError(t, err) + } +} diff --git a/tests/integration/repo_test.go b/tests/integration/repo_test.go new file mode 100644 index 0000000..b7a9dbb --- /dev/null +++ b/tests/integration/repo_test.go @@ -0,0 +1,1415 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "path" + "strings" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/translation" + repo_service "code.gitea.io/gitea/services/repository" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestViewRepo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user2/repo1") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + noDescription := htmlDoc.doc.Find("#repo-desc").Children() + repoTopics := htmlDoc.doc.Find("#repo-topics").Children() + repoSummary := htmlDoc.doc.Find(".repository-summary").Children() + + assert.True(t, noDescription.HasClass("no-description")) + assert.True(t, repoTopics.HasClass("repo-topic")) + assert.True(t, repoSummary.HasClass("repository-menu")) + + req = NewRequest(t, "GET", "/org3/repo3") + MakeRequest(t, req, http.StatusNotFound) + + session = loginUser(t, "user1") + session.MakeRequest(t, req, http.StatusNotFound) +} + +func testViewRepo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/org3/repo3") + session := loginUser(t, "user2") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + files := htmlDoc.doc.Find("#repo-files-table > TBODY > TR") + + type file struct { + fileName string + commitID string + commitMsg string + commitTime string + } + + var items []file + + files.Each(func(i int, s *goquery.Selection) { + tds := s.Find("td") + var f file + tds.Each(func(i int, s *goquery.Selection) { + if i == 0 { + f.fileName = strings.TrimSpace(s.Text()) + } else if i == 1 { + a := s.Find("a") + f.commitMsg = strings.TrimSpace(a.Text()) + l, _ := a.Attr("href") + f.commitID = path.Base(l) + } + }) + + // convert "2017-06-14 21:54:21 +0800" to "Wed, 14 Jun 2017 13:54:21 UTC" + htmlTimeString, _ := s.Find("relative-time").Attr("datetime") + htmlTime, _ := time.Parse(time.RFC3339, htmlTimeString) + f.commitTime = htmlTime.In(time.Local).Format(time.RFC1123) + items = append(items, f) + }) + + commitT := time.Date(2017, time.June, 14, 13, 54, 21, 0, time.UTC).In(time.Local).Format(time.RFC1123) + assert.EqualValues(t, []file{ + { + fileName: "doc", + commitID: "2a47ca4b614a9f5a43abbd5ad851a54a616ffee6", + commitMsg: "init project", + commitTime: commitT, + }, + { + fileName: "README.md", + commitID: "2a47ca4b614a9f5a43abbd5ad851a54a616ffee6", + commitMsg: "init project", + commitTime: commitT, + }, + }, items) +} + +func TestViewRepo2(t *testing.T) { + // no last commit cache + testViewRepo(t) + + // enable last commit cache for all repositories + oldCommitsCount := setting.CacheService.LastCommit.CommitsCount + setting.CacheService.LastCommit.CommitsCount = 0 + // first view will not hit the cache + testViewRepo(t) + // second view will hit the cache + testViewRepo(t) + setting.CacheService.LastCommit.CommitsCount = oldCommitsCount +} + +func TestViewRepo3(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/org3/repo3") + session := loginUser(t, "user4") + session.MakeRequest(t, req, http.StatusOK) +} + +func TestViewRepo1CloneLinkAnonymous(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/user2/repo1") + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + link, exists := htmlDoc.doc.Find("#repo-clone-https").Attr("data-link") + assert.True(t, exists, "The template has changed") + assert.Equal(t, setting.AppURL+"user2/repo1.git", link) + _, exists = htmlDoc.doc.Find("#repo-clone-ssh").Attr("data-link") + assert.False(t, exists) +} + +func TestViewRepo1CloneLinkAuthorized(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user2/repo1") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + link, exists := htmlDoc.doc.Find("#repo-clone-https").Attr("data-link") + assert.True(t, exists, "The template has changed") + assert.Equal(t, setting.AppURL+"user2/repo1.git", link) + link, exists = htmlDoc.doc.Find("#repo-clone-ssh").Attr("data-link") + assert.True(t, exists, "The template has changed") + sshURL := fmt.Sprintf("ssh://%s@%s:%d/user2/repo1.git", setting.SSH.User, setting.SSH.Domain, setting.SSH.Port) + assert.Equal(t, sshURL, link) +} + +func TestViewRepoWithSymlinks(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user2/repo20.git") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + files := htmlDoc.doc.Find("#repo-files-table > TBODY > TR > TD.name > SPAN.truncate") + items := files.Map(func(i int, s *goquery.Selection) string { + cls, _ := s.Find("SVG").Attr("class") + file := strings.Trim(s.Find("A").Text(), " \t\n") + return fmt.Sprintf("%s: %s", file, cls) + }) + assert.Len(t, items, 5) + assert.Equal(t, "a: tw-mr-2 svg octicon-file-directory-fill", items[0]) + assert.Equal(t, "link_b: tw-mr-2 svg octicon-file-directory-symlink", items[1]) + assert.Equal(t, "link_d: tw-mr-2 svg octicon-file-symlink-file", items[2]) + assert.Equal(t, "link_hi: tw-mr-2 svg octicon-file-symlink-file", items[3]) + assert.Equal(t, "link_link: tw-mr-2 svg octicon-file-symlink-file", items[4]) +} + +// TestViewAsRepoAdmin tests PR #2167 +func TestViewAsRepoAdmin(t *testing.T) { + for _, user := range []string{"user2", "user4"} { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, user) + + req := NewRequest(t, "GET", "/user2/repo1.git") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + noDescription := htmlDoc.doc.Find("#repo-desc").Children() + repoTopics := htmlDoc.doc.Find("#repo-topics").Children() + repoSummary := htmlDoc.doc.Find(".repository-summary").Children() + + assert.True(t, noDescription.HasClass("no-description")) + assert.True(t, repoTopics.HasClass("repo-topic")) + assert.True(t, repoSummary.HasClass("repository-menu")) + } +} + +func TestRepoHTMLTitle(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("Repository homepage", func(t *testing.T) { + t.Run("Without description", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + htmlTitle := GetHTMLTitle(t, nil, "/user2/repo1") + assert.EqualValues(t, "user2/repo1 - Forgejo: Beyond coding. We Forge.", htmlTitle) + }) + t.Run("With description", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + htmlTitle := GetHTMLTitle(t, nil, "/user27/repo49") + assert.EqualValues(t, "user27/repo49: A wonderful repository with more than just a README.md - Forgejo: Beyond coding. We Forge.", htmlTitle) + }) + }) + + t.Run("Code view", func(t *testing.T) { + t.Run("Directory", func(t *testing.T) { + t.Run("Default branch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/branch/master/deep/nesting") + assert.EqualValues(t, "repo59/deep/nesting at master - user2/repo59 - Forgejo: Beyond coding. We Forge.", htmlTitle) + }) + t.Run("Non-default branch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/branch/cake-recipe/deep/nesting") + assert.EqualValues(t, "repo59/deep/nesting at cake-recipe - user2/repo59 - Forgejo: Beyond coding. We Forge.", htmlTitle) + }) + t.Run("Commit", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/commit/d8f53dfb33f6ccf4169c34970b5e747511c18beb/deep/nesting/") + assert.EqualValues(t, "repo59/deep/nesting at d8f53dfb33f6ccf4169c34970b5e747511c18beb - user2/repo59 - Forgejo: Beyond coding. We Forge.", htmlTitle) + }) + t.Run("Tag", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/tag/v1.0/deep/nesting/") + assert.EqualValues(t, "repo59/deep/nesting at v1.0 - user2/repo59 - Forgejo: Beyond coding. We Forge.", htmlTitle) + }) + }) + t.Run("File", func(t *testing.T) { + t.Run("Default branch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/branch/master/deep/nesting/folder/secret_sauce_recipe.txt") + assert.EqualValues(t, "repo59/deep/nesting/folder/secret_sauce_recipe.txt at master - user2/repo59 - Forgejo: Beyond coding. We Forge.", htmlTitle) + }) + t.Run("Non-default branch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/branch/cake-recipe/deep/nesting/folder/secret_sauce_recipe.txt") + assert.EqualValues(t, "repo59/deep/nesting/folder/secret_sauce_recipe.txt at cake-recipe - user2/repo59 - Forgejo: Beyond coding. We Forge.", htmlTitle) + }) + t.Run("Commit", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/commit/d8f53dfb33f6ccf4169c34970b5e747511c18beb/deep/nesting/folder/secret_sauce_recipe.txt") + assert.EqualValues(t, "repo59/deep/nesting/folder/secret_sauce_recipe.txt at d8f53dfb33f6ccf4169c34970b5e747511c18beb - user2/repo59 - Forgejo: Beyond coding. We Forge.", htmlTitle) + }) + t.Run("Tag", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/tag/v1.0/deep/nesting/folder/secret_sauce_recipe.txt") + assert.EqualValues(t, "repo59/deep/nesting/folder/secret_sauce_recipe.txt at v1.0 - user2/repo59 - Forgejo: Beyond coding. We Forge.", htmlTitle) + }) + }) + }) + + t.Run("Issues view", func(t *testing.T) { + t.Run("Overview page", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + htmlTitle := GetHTMLTitle(t, nil, "/user2/repo1/issues") + assert.EqualValues(t, "Issues - user2/repo1 - Forgejo: Beyond coding. We Forge.", htmlTitle) + }) + t.Run("View issue page", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + htmlTitle := GetHTMLTitle(t, nil, "/user2/repo1/issues/1") + assert.EqualValues(t, "#1 - issue1 - user2/repo1 - Forgejo: Beyond coding. We Forge.", htmlTitle) + }) + }) + + t.Run("Pull requests view", func(t *testing.T) { + t.Run("Overview page", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + htmlTitle := GetHTMLTitle(t, nil, "/user2/repo1/pulls") + assert.EqualValues(t, "Pull requests - user2/repo1 - Forgejo: Beyond coding. We Forge.", htmlTitle) + }) + t.Run("View pull request", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + htmlTitle := GetHTMLTitle(t, nil, "/user2/repo1/pulls/2") + assert.EqualValues(t, "#2 - issue2 - user2/repo1 - Forgejo: Beyond coding. We Forge.", htmlTitle) + }) + }) +} + +// TestViewFileInRepo repo description, topics and summary should not be displayed when viewing a file +func TestViewFileInRepo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user2/repo1/src/branch/master/README.md") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + description := htmlDoc.doc.Find("#repo-desc") + repoTopics := htmlDoc.doc.Find("#repo-topics") + repoSummary := htmlDoc.doc.Find(".repository-summary") + + assert.EqualValues(t, 0, description.Length()) + assert.EqualValues(t, 0, repoTopics.Length()) + assert.EqualValues(t, 0, repoSummary.Length()) +} + +func TestViewFileInRepoRSSFeed(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + hasFileRSSFeed := func(t *testing.T, ref string) bool { + t.Helper() + + req := NewRequestf(t, "GET", "/user2/repo1/src/%s/README.md", ref) + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + fileFeed := htmlDoc.doc.Find(`a[href*="/user2/repo1/rss/"]`) + + return fileFeed.Length() != 0 + } + + t.Run("branch", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assert.True(t, hasFileRSSFeed(t, "branch/master")) + }) + + t.Run("tag", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assert.False(t, hasFileRSSFeed(t, "tag/v1.1")) + }) + + t.Run("commit", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assert.False(t, hasFileRSSFeed(t, "commit/65f1bf27bc3bf70f64657658635e66094edbcb4d")) + }) +} + +// TestBlameFileInRepo repo description, topics and summary should not be displayed when running blame on a file +func TestBlameFileInRepo(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + t.Run("Assert", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/blame/branch/master/README.md") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + description := htmlDoc.doc.Find("#repo-desc") + repoTopics := htmlDoc.doc.Find("#repo-topics") + repoSummary := htmlDoc.doc.Find(".repository-summary") + + assert.EqualValues(t, 0, description.Length()) + assert.EqualValues(t, 0, repoTopics.Length()) + assert.EqualValues(t, 0, repoSummary.Length()) + }) + + t.Run("File size", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + gitRepo, err := git.OpenRepository(git.DefaultContext, repo.RepoPath()) + require.NoError(t, err) + defer gitRepo.Close() + + commit, err := gitRepo.GetCommit("HEAD") + require.NoError(t, err) + + blob, err := commit.GetBlobByPath("README.md") + require.NoError(t, err) + + fileSize := blob.Size() + require.NotZero(t, fileSize) + + t.Run("Above maximum", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&setting.UI.MaxDisplayFileSize, fileSize)() + + req := NewRequest(t, "GET", "/user2/repo1/blame/branch/master/README.md") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, htmlDoc.Find(".code-view").Text(), translation.NewLocale("en-US").Tr("repo.file_too_large")) + }) + + t.Run("Under maximum", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&setting.UI.MaxDisplayFileSize, fileSize+1)() + + req := NewRequest(t, "GET", "/user2/repo1/blame/branch/master/README.md") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + assert.NotContains(t, htmlDoc.Find(".code-view").Text(), translation.NewLocale("en-US").Tr("repo.file_too_large")) + }) + }) +} + +// TestViewRepoDirectory repo description, topics and summary should not be displayed when within a directory +func TestViewRepoDirectory(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user2/repo20/src/branch/master/a") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + description := htmlDoc.doc.Find("#repo-desc") + repoTopics := htmlDoc.doc.Find("#repo-topics") + repoSummary := htmlDoc.doc.Find(".repository-summary") + + repoFilesTable := htmlDoc.doc.Find("#repo-files-table") + assert.NotEmpty(t, repoFilesTable.Nodes) + + assert.Zero(t, description.Length()) + assert.Zero(t, repoTopics.Length()) + assert.Zero(t, repoSummary.Length()) +} + +// ensure that the all the different ways to find and render a README work +func TestViewRepoDirectoryReadme(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // there are many combinations: + // - READMEs can be .md, .txt, or have no extension + // - READMEs can be tagged with a language and even a country code + // - READMEs can be stored in docs/, .gitea/, or .github/ + // - READMEs can be symlinks to other files + // - READMEs can be broken symlinks which should not render + // + // this doesn't cover all possible cases, just the major branches of the code + + session := loginUser(t, "user2") + + check := func(name, url, expectedFilename, expectedReadmeType, expectedContent string) { + t.Run(name, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", url) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + readmeName := htmlDoc.doc.Find("h4.file-header") + readmeContent := htmlDoc.doc.Find(".file-view") // TODO: add a id="readme" to the output to make this test more precise + readmeType, _ := readmeContent.Attr("class") + + assert.Equal(t, expectedFilename, strings.TrimSpace(readmeName.Text())) + assert.Contains(t, readmeType, expectedReadmeType) + assert.Contains(t, readmeContent.Text(), expectedContent) + }) + } + + // viewing the top level + check("Home", "/user2/readme-test/", "README.md", "markdown", "The cake is a lie.") + + // viewing different file extensions + check("md", "/user2/readme-test/src/branch/master/", "README.md", "markdown", "The cake is a lie.") + check("txt", "/user2/readme-test/src/branch/txt/", "README.txt", "plain-text", "My spoon is too big.") + check("plain", "/user2/readme-test/src/branch/plain/", "README", "plain-text", "Birken my stocks gee howdy") + check("i18n", "/user2/readme-test/src/branch/i18n/", "README.zh.md", "markdown", "蛋糕是一个谎言") + + // using HEAD ref + check("branch-HEAD", "/user2/readme-test/src/branch/HEAD/", "README.md", "markdown", "The cake is a lie.") + check("commit-HEAD", "/user2/readme-test/src/commit/HEAD/", "README.md", "markdown", "The cake is a lie.") + + // viewing different subdirectories + check("subdir", "/user2/readme-test/src/branch/subdir/libcake", "README.md", "markdown", "Four pints of sugar.") + check("docs-direct", "/user2/readme-test/src/branch/special-subdir-docs/docs/", "README.md", "markdown", "This is in docs/") + check("docs", "/user2/readme-test/src/branch/special-subdir-docs/", "docs/README.md", "markdown", "This is in docs/") + check(".gitea", "/user2/readme-test/src/branch/special-subdir-.gitea/", ".gitea/README.md", "markdown", "This is in .gitea/") + check(".github", "/user2/readme-test/src/branch/special-subdir-.github/", ".github/README.md", "markdown", "This is in .github/") + + // symlinks + // symlinks are subtle: + // - they should be able to handle going a reasonable number of times up and down in the tree + // - they shouldn't get stuck on link cycles + // - they should determine the filetype based on the name of the link, not the target + check("symlink", "/user2/readme-test/src/branch/symlink/", "README.md", "markdown", "This is in some/other/path") + check("symlink-multiple", "/user2/readme-test/src/branch/symlink/some/", "README.txt", "plain-text", "This is in some/other/path") + check("symlink-up-and-down", "/user2/readme-test/src/branch/symlink/up/back/down/down", "README.md", "markdown", "It's a me, mario") + + // testing fallback rules + // READMEs are searched in this order: + // - [README.zh-cn.md, README.zh_cn.md, README.zh.md, README_zh.md, README.md, README.txt, README, + // docs/README.zh-cn.md, docs/README.zh_cn.md, docs/README.zh.md, docs/README_zh.md, docs/README.md, docs/README.txt, docs/README, + // .gitea/README.zh-cn.md, .gitea/README.zh_cn.md, .gitea/README.zh.md, .gitea/README_zh.md, .gitea/README.md, .gitea/README.txt, .gitea/README, + + // .github/README.zh-cn.md, .github/README.zh_cn.md, .github/README.zh.md, .github/README_zh.md, .github/README.md, .github/README.txt, .github/README] + // and a broken/looped symlink counts as not existing at all and should be skipped. + // again, this doesn't cover all cases, but it covers a few + check("fallback/top", "/user2/readme-test/src/branch/fallbacks/", "README.en.md", "markdown", "This is README.en.md") + check("fallback/2", "/user2/readme-test/src/branch/fallbacks2/", "README.md", "markdown", "This is README.md") + check("fallback/3", "/user2/readme-test/src/branch/fallbacks3/", "README", "plain-text", "This is README") + check("fallback/4", "/user2/readme-test/src/branch/fallbacks4/", "docs/README.en.md", "markdown", "This is docs/README.en.md") + check("fallback/5", "/user2/readme-test/src/branch/fallbacks5/", "docs/README.md", "markdown", "This is docs/README.md") + check("fallback/6", "/user2/readme-test/src/branch/fallbacks6/", "docs/README", "plain-text", "This is docs/README") + check("fallback/7", "/user2/readme-test/src/branch/fallbacks7/", ".gitea/README.en.md", "markdown", "This is .gitea/README.en.md") + check("fallback/8", "/user2/readme-test/src/branch/fallbacks8/", ".gitea/README.md", "markdown", "This is .gitea/README.md") + check("fallback/9", "/user2/readme-test/src/branch/fallbacks9/", ".gitea/README", "plain-text", "This is .gitea/README") + + // this case tests that broken symlinks count as missing files, instead of rendering their contents + check("fallbacks-broken-symlinks", "/user2/readme-test/src/branch/fallbacks-broken-symlinks/", "docs/README", "plain-text", "This is docs/README") + + // some cases that should NOT render a README + // - /readme + // - /.github/docs/README.md + // - a symlink loop + + missing := func(name, url string) { + t.Run("missing/"+name, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", url) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + _, exists := htmlDoc.doc.Find(".file-view").Attr("class") + + assert.False(t, exists, "README should not have rendered") + }) + } + missing("sp-ace", "/user2/readme-test/src/branch/sp-ace/") + missing("nested-special", "/user2/readme-test/src/branch/special-subdir-nested/subproject") // the special subdirs should only trigger on the repo root + missing("special-subdir-nested", "/user2/readme-test/src/branch/special-subdir-nested/") + missing("symlink-loop", "/user2/readme-test/src/branch/symlink-loop/") +} + +func TestRenamedFileHistory(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("Renamed file", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo59/commits/branch/master/license") + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + renameNotice := htmlDoc.doc.Find(".ui.bottom.attached.header") + assert.Equal(t, 1, renameNotice.Length()) + assert.Contains(t, renameNotice.Text(), "Renamed from licnse (Browse further)") + + oldFileHistoryLink, ok := renameNotice.Find("a").Attr("href") + assert.True(t, ok) + assert.Equal(t, "/user2/repo59/commits/commit/80b83c5c8220c3aa3906e081f202a2a7563ec879/licnse", oldFileHistoryLink) + }) + + t.Run("Non renamed file", func(t *testing.T) { + req := NewRequest(t, "GET", "/user2/repo59/commits/branch/master/README.md") + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, ".ui.bottom.attached.header", false) + }) +} + +func TestMarkDownReadmeImage(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user2/repo1/src/branch/home-md-img-check") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + src, exists := htmlDoc.doc.Find(`.markdown img`).Attr("src") + assert.True(t, exists, "Image not found in README") + assert.Equal(t, "/user2/repo1/media/branch/home-md-img-check/test-fake-img.jpg", src) + + req = NewRequest(t, "GET", "/user2/repo1/src/branch/home-md-img-check/README.md") + resp = session.MakeRequest(t, req, http.StatusOK) + + htmlDoc = NewHTMLParser(t, resp.Body) + src, exists = htmlDoc.doc.Find(`.markdown img`).Attr("src") + assert.True(t, exists, "Image not found in markdown file") + assert.Equal(t, "/user2/repo1/media/branch/home-md-img-check/test-fake-img.jpg", src) +} + +func TestMarkDownReadmeImageSubfolder(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + // this branch has the README in the special docs/README.md location + req := NewRequest(t, "GET", "/user2/repo1/src/branch/sub-home-md-img-check") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + src, exists := htmlDoc.doc.Find(`.markdown img`).Attr("src") + assert.True(t, exists, "Image not found in README") + assert.Equal(t, "/user2/repo1/media/branch/sub-home-md-img-check/docs/test-fake-img.jpg", src) + + req = NewRequest(t, "GET", "/user2/repo1/src/branch/sub-home-md-img-check/docs/README.md") + resp = session.MakeRequest(t, req, http.StatusOK) + + htmlDoc = NewHTMLParser(t, resp.Body) + src, exists = htmlDoc.doc.Find(`.markdown img`).Attr("src") + assert.True(t, exists, "Image not found in markdown file") + assert.Equal(t, "/user2/repo1/media/branch/sub-home-md-img-check/docs/test-fake-img.jpg", src) +} + +func TestGeneratedSourceLink(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("Rendered file", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequest(t, "GET", "/user2/repo1/src/branch/master/README.md?display=source") + resp := MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + dataURL, exists := doc.doc.Find(".copy-line-permalink").Attr("data-url") + assert.True(t, exists) + assert.Equal(t, "/user2/repo1/src/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d/README.md?display=source", dataURL) + + dataURL, exists = doc.doc.Find(".ref-in-new-issue").Attr("data-url-param-body-link") + assert.True(t, exists) + assert.Equal(t, "/user2/repo1/src/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d/README.md?display=source", dataURL) + }) + + t.Run("Non-Rendered file", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, "user27") + req := NewRequest(t, "GET", "/user27/repo49/src/branch/master/test/test.txt") + resp := session.MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + + dataURL, exists := doc.doc.Find(".copy-line-permalink").Attr("data-url") + assert.True(t, exists) + assert.Equal(t, "/user27/repo49/src/commit/aacbdfe9e1c4b47f60abe81849045fa4e96f1d75/test/test.txt", dataURL) + + dataURL, exists = doc.doc.Find(".ref-in-new-issue").Attr("data-url-param-body-link") + assert.True(t, exists) + assert.Equal(t, "/user27/repo49/src/commit/aacbdfe9e1c4b47f60abe81849045fa4e96f1d75/test/test.txt", dataURL) + }) +} + +func TestViewCommit(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/user2/repo1/commit/0123456789012345678901234567890123456789") + req.Header.Add("Accept", "text/html") + resp := MakeRequest(t, req, http.StatusNotFound) + assert.True(t, test.IsNormalPageCompleted(resp.Body.String()), "non-existing commit should render 404 page") +} + +func TestCommitView(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("Non-existent commit", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/commit/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + req.SetHeader("Accept", "text/html") + resp := MakeRequest(t, req, http.StatusNotFound) + + // Really ensure that 404 is being sent back. + doc := NewHTMLParser(t, resp.Body) + doc.AssertElement(t, `[aria-label="Page Not Found"]`, true) + }) + + t.Run("Too short commit ID", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/commit/65f") + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("Short commit ID", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/commit/65f1") + resp := MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + commitTitle := doc.Find(".commit-summary").Text() + assert.Contains(t, commitTitle, "Initial commit") + + req = NewRequest(t, "GET", "/user2/repo1/src/commit/65f1") + resp = MakeRequest(t, req, http.StatusOK) + + doc = NewHTMLParser(t, resp.Body) + commitTitle = doc.Find(".shortsha").Text() + assert.Contains(t, commitTitle, "65f1bf27bc") + }) + + t.Run("Full commit ID", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d") + resp := MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + commitTitle := doc.Find(".commit-summary").Text() + assert.Contains(t, commitTitle, "Initial commit") + }) +} + +func TestRepoHomeViewRedirect(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("Code", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1") + resp := MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + l := doc.Find("#repo-desc").Length() + assert.Equal(t, 1, l) + }) + + t.Run("No Code redirects to Issues", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Disable the Code unit + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, nil, []unit_model.Type{ + unit_model.TypeCode, + }) + require.NoError(t, err) + + // The repo home should redirect to the built-in issue tracker + req := NewRequest(t, "GET", "/user2/repo1") + resp := MakeRequest(t, req, http.StatusSeeOther) + redir := resp.Header().Get("Location") + + assert.Equal(t, "/user2/repo1/issues", redir) + }) + + t.Run("No Code and ExternalTracker redirects to Pulls", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Replace the internal tracker with an external one + // Disable Code, Projects, Packages, and Actions + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, []repo_model.RepoUnit{{ + RepoID: repo.ID, + Type: unit_model.TypeExternalTracker, + Config: &repo_model.ExternalTrackerConfig{ + ExternalTrackerURL: "https://example.com", + }, + }}, []unit_model.Type{ + unit_model.TypeCode, + unit_model.TypeIssues, + unit_model.TypeProjects, + unit_model.TypePackages, + unit_model.TypeActions, + }) + require.NoError(t, err) + + // The repo home should redirect to pull requests + req := NewRequest(t, "GET", "/user2/repo1") + resp := MakeRequest(t, req, http.StatusSeeOther) + redir := resp.Header().Get("Location") + + assert.Equal(t, "/user2/repo1/pulls", redir) + }) + + t.Run("Only external wiki results in 404", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Replace the internal wiki with an external, and disable everything + // else. + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, []repo_model.RepoUnit{{ + RepoID: repo.ID, + Type: unit_model.TypeExternalWiki, + Config: &repo_model.ExternalWikiConfig{ + ExternalWikiURL: "https://example.com", + }, + }}, []unit_model.Type{ + unit_model.TypeCode, + unit_model.TypeIssues, + unit_model.TypeExternalTracker, + unit_model.TypeProjects, + unit_model.TypePackages, + unit_model.TypeActions, + unit_model.TypePullRequests, + unit_model.TypeReleases, + unit_model.TypeWiki, + }) + require.NoError(t, err) + + // The repo home ends up being 404 + req := NewRequest(t, "GET", "/user2/repo1") + req.Header.Set("Accept", "text/html") + resp := MakeRequest(t, req, http.StatusNotFound) + + // The external wiki is linked to from the 404 page + doc := NewHTMLParser(t, resp.Body) + txt := strings.TrimSpace(doc.Find(`a[href="https://example.com"]`).Text()) + assert.Equal(t, "Wiki", txt) + }) +} + +func TestRepoFilesList(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // create the repo + repo, _, f := tests.CreateDeclarativeRepo(t, user2, "", + []unit_model.Type{unit_model.TypeCode}, nil, + []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "zEta", + ContentReader: strings.NewReader("zeta"), + }, + { + Operation: "create", + TreePath: "licensa", + ContentReader: strings.NewReader("licensa"), + }, + { + Operation: "create", + TreePath: "licensz", + ContentReader: strings.NewReader("licensz"), + }, + { + Operation: "create", + TreePath: "delta", + ContentReader: strings.NewReader("delta"), + }, + { + Operation: "create", + TreePath: "Charlie/aa.txt", + ContentReader: strings.NewReader("charlie"), + }, + { + Operation: "create", + TreePath: "Beta", + ContentReader: strings.NewReader("beta"), + }, + { + Operation: "create", + TreePath: "alpha", + ContentReader: strings.NewReader("alpha"), + }, + }, + ) + defer f() + + req := NewRequest(t, "GET", "/"+repo.FullName()) + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + filesList := htmlDoc.Find("#repo-files-table tbody tr").Map(func(_ int, s *goquery.Selection) string { + return s.AttrOr("data-entryname", "") + }) + + assert.EqualValues(t, []string{"Charlie", "alpha", "Beta", "delta", "licensa", "LICENSE", "licensz", "README.md", "zEta"}, filesList) + }) +} + +func TestRepoFollowSymlink(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + + assertCase := func(t *testing.T, url, expectedSymlinkURL string, shouldExist bool) { + t.Helper() + + req := NewRequest(t, "GET", url) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + symlinkURL, ok := htmlDoc.Find(".file-actions .button[data-kind='follow-symlink']").Attr("href") + if shouldExist { + assert.True(t, ok) + assert.EqualValues(t, expectedSymlinkURL, symlinkURL) + } else { + assert.False(t, ok) + } + } + + t.Run("Normal", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + assertCase(t, "/user2/readme-test/src/branch/symlink/README.md?display=source", "/user2/readme-test/src/branch/symlink/some/other/path/awefulcake.txt", true) + }) + + t.Run("Normal", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + assertCase(t, "/user2/readme-test/src/branch/symlink/some/README.txt", "/user2/readme-test/src/branch/symlink/some/other/path/awefulcake.txt", true) + }) + + t.Run("Normal", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + assertCase(t, "/user2/readme-test/src/branch/symlink/up/back/down/down/README.md", "/user2/readme-test/src/branch/symlink/down/side/../left/right/../reelmein", true) + }) + + t.Run("Broken symlink", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + assertCase(t, "/user2/readme-test/src/branch/fallbacks-broken-symlinks/docs/README", "", false) + }) + + t.Run("Loop symlink", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + assertCase(t, "/user2/readme-test/src/branch/symlink-loop/README.md", "", false) + }) + + t.Run("Not a symlink", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + assertCase(t, "/user2/readme-test/src/branch/master/README.md", "", false) + }) +} + +func TestViewRepoOpenWith(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + getOpenWith := func() []string { + req := NewRequest(t, "GET", "/user2/repo1") + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + openWithHTML := htmlDoc.doc.Find(".js-clone-url-editor") + + var methods []string + openWithHTML.Each(func(i int, s *goquery.Selection) { + a, _ := s.Attr("data-href-template") + methods = append(methods, a) + }) + + return methods + } + + testOpenWith := func(expected []string) { + methods := getOpenWith() + + assert.Len(t, methods, len(expected)) + for i, expectedMethod := range expected { + assert.True(t, strings.HasPrefix(methods[i], expectedMethod)) + } + } + + t.Run("Defaults", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testOpenWith([]string{"vscode://", "vscodium://", "jetbrains://"}) + }) + + t.Run("Customised", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Change the methods via the admin settings + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) + session := loginUser(t, user.Name) + + setEditorApps := func(t *testing.T, apps string) { + t.Helper() + + req := NewRequestWithValues(t, "POST", "/admin/config?key=repository.open-with.editor-apps", map[string]string{ + "value": apps, + "_csrf": GetCSRF(t, session, "/admin/config/settings"), + }) + session.MakeRequest(t, req, http.StatusOK) + } + + defer func() { + setEditorApps(t, "") + }() + + setEditorApps(t, "test = test://?url={url}") + + testOpenWith([]string{"test://"}) + }) +} + +func TestRepoCodeSearchForm(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + testSearchForm := func(t *testing.T, indexer bool) { + defer test.MockVariableValue(&setting.Indexer.RepoIndexerEnabled, indexer)() + req := NewRequest(t, "GET", "/user2/repo1/src/branch/master") + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + action, exists := htmlDoc.doc.Find("form[data-test-tag=codesearch]").Attr("action") + assert.True(t, exists) + + branchSubURL := "/branch/master" + + if indexer { + assert.NotContains(t, action, branchSubURL) + } else { + assert.Contains(t, action, branchSubURL) + } + } + + t.Run("indexer disabled", func(t *testing.T) { + testSearchForm(t, false) + }) + + t.Run("indexer enabled", func(t *testing.T) { + testSearchForm(t, true) + }) +} + +func TestFileHistoryPager(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("Normal page number", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/commits/branch/master/README.md?page=1") + MakeRequest(t, req, http.StatusOK) + }) + + t.Run("Too high page number", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/commits/branch/master/README.md?page=9999") + MakeRequest(t, req, http.StatusNotFound) + }) +} + +func TestRepoIssueFilterLinks(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("No filters", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + called := false + htmlDoc.Find("#issue-filters a[href^='?']").Each(func(_ int, s *goquery.Selection) { + called = true + href, _ := s.Attr("href") + assert.Contains(t, href, "?q=&") + assert.Contains(t, href, "&type=") + assert.Contains(t, href, "&sort=") + assert.Contains(t, href, "&state=") + assert.Contains(t, href, "&labels=") + assert.Contains(t, href, "&milestone=") + assert.Contains(t, href, "&project=") + assert.Contains(t, href, "&assignee=") + assert.Contains(t, href, "&poster=") + assert.Contains(t, href, "&fuzzy=") + }) + assert.True(t, called) + }) + + t.Run("Keyword", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues?q=search-on-this") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + called := false + htmlDoc.Find("#issue-filters a[href^='?']").Each(func(_ int, s *goquery.Selection) { + called = true + href, _ := s.Attr("href") + assert.Contains(t, href, "?q=search-on-this") + assert.Contains(t, href, "&type=") + assert.Contains(t, href, "&sort=") + assert.Contains(t, href, "&state=") + assert.Contains(t, href, "&labels=") + assert.Contains(t, href, "&milestone=") + assert.Contains(t, href, "&project=") + assert.Contains(t, href, "&assignee=") + assert.Contains(t, href, "&poster=") + assert.Contains(t, href, "&fuzzy=") + }) + assert.True(t, called) + }) + + t.Run("Fuzzy", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues?fuzzy=true") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + called := false + htmlDoc.Find("#issue-filters a[href^='?']").Each(func(_ int, s *goquery.Selection) { + called = true + href, _ := s.Attr("href") + assert.Contains(t, href, "?q=&") + assert.Contains(t, href, "&type=") + assert.Contains(t, href, "&sort=") + assert.Contains(t, href, "&state=") + assert.Contains(t, href, "&labels=") + assert.Contains(t, href, "&milestone=") + assert.Contains(t, href, "&project=") + assert.Contains(t, href, "&assignee=") + assert.Contains(t, href, "&poster=") + assert.Contains(t, href, "&fuzzy=true") + }) + assert.True(t, called) + }) + + t.Run("Sort", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues?sort=oldest") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + called := false + htmlDoc.Find("#issue-filters a[href^='?']:not(.list-header-sort a)").Each(func(_ int, s *goquery.Selection) { + called = true + href, _ := s.Attr("href") + assert.Contains(t, href, "?q=&") + assert.Contains(t, href, "&type=") + assert.Contains(t, href, "&sort=oldest") + assert.Contains(t, href, "&state=") + assert.Contains(t, href, "&labels=") + assert.Contains(t, href, "&milestone=") + assert.Contains(t, href, "&project=") + assert.Contains(t, href, "&assignee=") + assert.Contains(t, href, "&poster=") + assert.Contains(t, href, "&fuzzy=") + }) + assert.True(t, called) + }) + + t.Run("Type", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues?type=assigned") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + called := false + htmlDoc.Find("#issue-filters a[href^='?']:not(.list-header-type a)").Each(func(_ int, s *goquery.Selection) { + called = true + href, _ := s.Attr("href") + assert.Contains(t, href, "?q=&") + assert.Contains(t, href, "&type=assigned") + assert.Contains(t, href, "&sort=") + assert.Contains(t, href, "&state=") + assert.Contains(t, href, "&labels=") + assert.Contains(t, href, "&milestone=") + assert.Contains(t, href, "&project=") + assert.Contains(t, href, "&assignee=") + assert.Contains(t, href, "&poster=") + assert.Contains(t, href, "&fuzzy=") + }) + assert.True(t, called) + }) + + t.Run("State", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues?state=closed") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + called := false + htmlDoc.Find("#issue-filters a[href^='?']:not(.issue-list-toolbar-left a)").Each(func(_ int, s *goquery.Selection) { + called = true + href, _ := s.Attr("href") + assert.Contains(t, href, "?q=&") + assert.Contains(t, href, "&type=") + assert.Contains(t, href, "&sort=") + assert.Contains(t, href, "&state=closed") + assert.Contains(t, href, "&labels=") + assert.Contains(t, href, "&milestone=") + assert.Contains(t, href, "&project=") + assert.Contains(t, href, "&assignee=") + assert.Contains(t, href, "&poster=") + assert.Contains(t, href, "&fuzzy=") + }) + assert.True(t, called) + }) + + t.Run("Miilestone", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues?milestone=1") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + called := false + htmlDoc.Find("#issue-filters a[href^='?']:not(.list-header-milestone a)").Each(func(_ int, s *goquery.Selection) { + called = true + href, _ := s.Attr("href") + assert.Contains(t, href, "?q=&") + assert.Contains(t, href, "&type=") + assert.Contains(t, href, "&sort=") + assert.Contains(t, href, "&state=") + assert.Contains(t, href, "&labels=") + assert.Contains(t, href, "&milestone=1") + assert.Contains(t, href, "&project=") + assert.Contains(t, href, "&assignee=") + assert.Contains(t, href, "&poster=") + assert.Contains(t, href, "&fuzzy=") + }) + assert.True(t, called) + }) + + t.Run("Milestone", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues?milestone=1") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + called := false + htmlDoc.Find("#issue-filters a[href^='?']:not(.list-header-milestone a)").Each(func(_ int, s *goquery.Selection) { + called = true + href, _ := s.Attr("href") + assert.Contains(t, href, "?q=&") + assert.Contains(t, href, "&type=") + assert.Contains(t, href, "&sort=") + assert.Contains(t, href, "&state=") + assert.Contains(t, href, "&labels=") + assert.Contains(t, href, "&milestone=1") + assert.Contains(t, href, "&project=") + assert.Contains(t, href, "&assignee=") + assert.Contains(t, href, "&poster=") + assert.Contains(t, href, "&fuzzy=") + }) + assert.True(t, called) + }) + + t.Run("Project", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues?project=1") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + called := false + htmlDoc.Find("#issue-filters a[href^='?']:not(.list-header-project a)").Each(func(_ int, s *goquery.Selection) { + called = true + href, _ := s.Attr("href") + assert.Contains(t, href, "?q=&") + assert.Contains(t, href, "&type=") + assert.Contains(t, href, "&sort=") + assert.Contains(t, href, "&state=") + assert.Contains(t, href, "&labels=") + assert.Contains(t, href, "&milestone=") + assert.Contains(t, href, "&project=1") + assert.Contains(t, href, "&assignee=") + assert.Contains(t, href, "&poster=") + assert.Contains(t, href, "&fuzzy=") + }) + assert.True(t, called) + }) + + t.Run("Assignee", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues?assignee=1") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + called := false + htmlDoc.Find("#issue-filters a[href^='?']:not(.list-header-assignee a)").Each(func(_ int, s *goquery.Selection) { + called = true + href, _ := s.Attr("href") + assert.Contains(t, href, "?q=&") + assert.Contains(t, href, "&type=") + assert.Contains(t, href, "&sort=") + assert.Contains(t, href, "&state=") + assert.Contains(t, href, "&labels=") + assert.Contains(t, href, "&milestone=") + assert.Contains(t, href, "&project=") + assert.Contains(t, href, "&assignee=1") + assert.Contains(t, href, "&poster=") + assert.Contains(t, href, "&fuzzy=") + }) + assert.True(t, called) + }) + + t.Run("Poster", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues?poster=1") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + called := false + htmlDoc.Find("#issue-filters a[href^='?']:not(.list-header-poster a)").Each(func(_ int, s *goquery.Selection) { + called = true + href, _ := s.Attr("href") + assert.Contains(t, href, "?q=&") + assert.Contains(t, href, "&type=") + assert.Contains(t, href, "&sort=") + assert.Contains(t, href, "&state=") + assert.Contains(t, href, "&labels=") + assert.Contains(t, href, "&milestone=") + assert.Contains(t, href, "&project=") + assert.Contains(t, href, "&assignee=") + assert.Contains(t, href, "&poster=1") + assert.Contains(t, href, "&fuzzy=") + }) + assert.True(t, called) + }) + + t.Run("Labels", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues?labels=1") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + called := false + htmlDoc.Find("#issue-filters a[href^='?']:not(.label-filter a)").Each(func(_ int, s *goquery.Selection) { + called = true + href, _ := s.Attr("href") + assert.Contains(t, href, "?q=&") + assert.Contains(t, href, "&type=") + assert.Contains(t, href, "&sort=") + assert.Contains(t, href, "&state=") + assert.Contains(t, href, "&labels=1") + assert.Contains(t, href, "&milestone=") + assert.Contains(t, href, "&project=") + assert.Contains(t, href, "&assignee=") + assert.Contains(t, href, "&poster=") + assert.Contains(t, href, "&fuzzy=") + }) + assert.True(t, called) + }) + + t.Run("Archived labels", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2/repo1/issues?archived=true") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + called := false + htmlDoc.Find("#issue-filters a[href^='?']").Each(func(_ int, s *goquery.Selection) { + called = true + href, _ := s.Attr("href") + assert.Contains(t, href, "?q=&") + assert.Contains(t, href, "&type=") + assert.Contains(t, href, "&sort=") + assert.Contains(t, href, "&state=") + assert.Contains(t, href, "&labels=") + assert.Contains(t, href, "&milestone=") + assert.Contains(t, href, "&project=") + assert.Contains(t, href, "&assignee=") + assert.Contains(t, href, "&poster=") + assert.Contains(t, href, "&fuzzy=") + assert.Contains(t, href, "&archived=true") + }) + assert.True(t, called) + }) +} + +func TestRepoSubmoduleView(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo, _, f := tests.CreateDeclarativeRepo(t, user2, "", []unit_model.Type{unit_model.TypeCode}, nil, nil) + defer f() + + // Clone the repository, add a submodule and push it. + dstPath := t.TempDir() + + uClone := *u + uClone.Path = repo.FullName() + uClone.User = url.UserPassword(user2.Name, userPassword) + + t.Run("Clone", doGitClone(dstPath, &uClone)) + + _, _, err := git.NewCommand(git.DefaultContext, "submodule", "add").AddDynamicArguments(u.JoinPath("/user2/repo1").String()).RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + _, _, err = git.NewCommand(git.DefaultContext, "add", "repo1", ".gitmodules").RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + _, _, err = git.NewCommand(git.DefaultContext, "commit", "-m", "add submodule").RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + _, _, err = git.NewCommand(git.DefaultContext, "push").RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + // Check that the submodule entry exist and the link is correct. + req := NewRequest(t, "GET", "/"+repo.FullName()) + resp := MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, fmt.Sprintf(`tr[data-entryname="repo1"] a[href="%s"]`, u.JoinPath("/user2/repo1").String()), true) + }) +} diff --git a/tests/integration/repo_topic_test.go b/tests/integration/repo_topic_test.go new file mode 100644 index 0000000..f5778a2 --- /dev/null +++ b/tests/integration/repo_topic_test.go @@ -0,0 +1,81 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestTopicSearch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + searchURL, _ := url.Parse("/explore/topics/search") + var topics struct { + TopicNames []*api.TopicResponse `json:"topics"` + } + + query := url.Values{"page": []string{"1"}, "limit": []string{"4"}} + + searchURL.RawQuery = query.Encode() + res := MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) + DecodeJSON(t, res, &topics) + assert.Len(t, topics.TopicNames, 4) + assert.EqualValues(t, "6", res.Header().Get("x-total-count")) + + query.Add("q", "topic") + searchURL.RawQuery = query.Encode() + res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) + DecodeJSON(t, res, &topics) + assert.Len(t, topics.TopicNames, 2) + + query.Set("q", "database") + searchURL.RawQuery = query.Encode() + res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) + DecodeJSON(t, res, &topics) + if assert.Len(t, topics.TopicNames, 1) { + assert.EqualValues(t, 2, topics.TopicNames[0].ID) + assert.EqualValues(t, "database", topics.TopicNames[0].Name) + assert.EqualValues(t, 1, topics.TopicNames[0].RepoCount) + } +} + +func TestTopicSearchPaging(t *testing.T) { + defer tests.PrepareTestEnv(t)() + var topics struct { + TopicNames []*api.TopicResponse `json:"topics"` + } + + // Add 20 unique topics to user2/repo2, and 20 unique ones to user2/repo3 + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + token2 := getUserToken(t, user2.Name, auth_model.AccessTokenScopeWriteRepository) + repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + for i := 0; i < 20; i++ { + req := NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/paging-topic-%d", user2.Name, repo2.Name, i). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusNoContent) + req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/paging-topic-%d", user2.Name, repo3.Name, i+30). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusNoContent) + } + + res := MakeRequest(t, NewRequest(t, "GET", "/explore/topics/search"), http.StatusOK) + DecodeJSON(t, res, &topics) + assert.Len(t, topics.TopicNames, 30) + + res = MakeRequest(t, NewRequest(t, "GET", "/explore/topics/search?page=2"), http.StatusOK) + DecodeJSON(t, res, &topics) + assert.NotEmpty(t, topics.TopicNames) +} diff --git a/tests/integration/repo_view_test.go b/tests/integration/repo_view_test.go new file mode 100644 index 0000000..7c280e2 --- /dev/null +++ b/tests/integration/repo_view_test.go @@ -0,0 +1,230 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "testing" + + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/routers/web/repo" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/contexttest" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func createRepoAndGetContext(t *testing.T, files []string, deleteMdReadme bool) (*context.Context, func()) { + t.Helper() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"}) + + size := len(files) + if deleteMdReadme { + size++ + } + changeFiles := make([]*files_service.ChangeRepoFile, size) + for i, e := range files { + changeFiles[i] = &files_service.ChangeRepoFile{ + Operation: "create", + TreePath: e, + ContentReader: strings.NewReader("test"), + } + } + if deleteMdReadme { + changeFiles[len(files)] = &files_service.ChangeRepoFile{ + Operation: "delete", + TreePath: "README.md", + } + } + + // README.md is already added by auto init + repo, _, f := tests.CreateDeclarativeRepo(t, user, "readmetest", []unit_model.Type{unit_model.TypeCode}, nil, changeFiles) + + ctx, _ := contexttest.MockContext(t, "user1/readmetest") + ctx.SetParams(":id", fmt.Sprint(repo.ID)) + contexttest.LoadRepo(t, ctx, repo.ID) + contexttest.LoadGitRepo(t, ctx) + contexttest.LoadRepoCommit(t, ctx) + + return ctx, func() { + f() + ctx.Repo.GitRepo.Close() + } +} + +func TestRepoView_FindReadme(t *testing.T) { + t.Run("PrioOneLocalizedMdReadme", func(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + ctx, f := createRepoAndGetContext(t, []string{"README.en.md", "README.en.org", "README.org", "README.txt", "README.tex"}, false) + defer f() + + tree, _ := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) + entries, _ := tree.ListEntries() + _, file, _ := repo.FindReadmeFileInEntries(ctx, entries, false) + + assert.Equal(t, "README.en.md", file.Name()) + }) + }) + t.Run("PrioTwoMdReadme", func(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + ctx, f := createRepoAndGetContext(t, []string{"README.en.org", "README.org", "README.txt", "README.tex"}, false) + defer f() + + tree, _ := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) + entries, _ := tree.ListEntries() + _, file, _ := repo.FindReadmeFileInEntries(ctx, entries, false) + + assert.Equal(t, "README.md", file.Name()) + }) + }) + t.Run("PrioThreeLocalizedOrgReadme", func(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + ctx, f := createRepoAndGetContext(t, []string{"README.en.org", "README.org", "README.txt", "README.tex"}, true) + defer f() + + tree, _ := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) + entries, _ := tree.ListEntries() + _, file, _ := repo.FindReadmeFileInEntries(ctx, entries, false) + + assert.Equal(t, "README.en.org", file.Name()) + }) + }) + t.Run("PrioFourOrgReadme", func(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + ctx, f := createRepoAndGetContext(t, []string{"README.org", "README.txt", "README.tex"}, true) + defer f() + + tree, _ := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) + entries, _ := tree.ListEntries() + _, file, _ := repo.FindReadmeFileInEntries(ctx, entries, false) + + assert.Equal(t, "README.org", file.Name()) + }) + }) + t.Run("PrioFiveTxtReadme", func(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + ctx, f := createRepoAndGetContext(t, []string{"README.txt", "README", "README.tex"}, true) + defer f() + + tree, _ := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) + entries, _ := tree.ListEntries() + _, file, _ := repo.FindReadmeFileInEntries(ctx, entries, false) + + assert.Equal(t, "README.txt", file.Name()) + }) + }) + t.Run("PrioSixWithoutExtensionReadme", func(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + ctx, f := createRepoAndGetContext(t, []string{"README", "README.tex"}, true) + defer f() + + tree, _ := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) + entries, _ := tree.ListEntries() + _, file, _ := repo.FindReadmeFileInEntries(ctx, entries, false) + + assert.Equal(t, "README", file.Name()) + }) + }) + t.Run("PrioSevenAnyReadme", func(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + ctx, f := createRepoAndGetContext(t, []string{"README.tex"}, true) + defer f() + + tree, _ := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) + entries, _ := tree.ListEntries() + _, file, _ := repo.FindReadmeFileInEntries(ctx, entries, false) + + assert.Equal(t, "README.tex", file.Name()) + }) + }) + t.Run("DoNotPickReadmeIfNonPresent", func(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + ctx, f := createRepoAndGetContext(t, []string{}, true) + defer f() + + tree, _ := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) + entries, _ := tree.ListEntries() + _, file, _ := repo.FindReadmeFileInEntries(ctx, entries, false) + + assert.Nil(t, file) + }) + }) +} + +func TestRepoViewFileLines(t *testing.T) { + onGiteaRun(t, func(t *testing.T, _ *url.URL) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo, _, f := tests.CreateDeclarativeRepo(t, user, "file-lines", []unit_model.Type{unit_model.TypeCode}, nil, []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "test-1", + ContentReader: strings.NewReader("No newline"), + }, + { + Operation: "create", + TreePath: "test-2", + ContentReader: strings.NewReader("No newline\n"), + }, + { + Operation: "create", + TreePath: "test-3", + ContentReader: strings.NewReader("Two\nlines"), + }, + { + Operation: "create", + TreePath: "test-4", + ContentReader: strings.NewReader("Really two\nlines\n"), + }, + { + Operation: "create", + TreePath: "empty", + ContentReader: strings.NewReader(""), + }, + { + Operation: "create", + TreePath: "seemingly-empty", + ContentReader: strings.NewReader("\n"), + }, + }) + defer f() + + testEOL := func(t *testing.T, filename string, hasEOL bool) { + t.Helper() + req := NewRequestf(t, "GET", "%s/src/branch/main/%s", repo.Link(), filename) + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + fileInfo := htmlDoc.Find(".file-info").Text() + if hasEOL { + assert.NotContains(t, fileInfo, "No EOL") + } else { + assert.Contains(t, fileInfo, "No EOL") + } + } + + t.Run("No EOL", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testEOL(t, "test-1", false) + testEOL(t, "test-3", false) + }) + + t.Run("With EOL", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + testEOL(t, "test-2", true) + testEOL(t, "test-4", true) + testEOL(t, "empty", true) + testEOL(t, "seemingly-empty", true) + }) + }) +} diff --git a/tests/integration/repo_watch_test.go b/tests/integration/repo_watch_test.go new file mode 100644 index 0000000..ef3028f --- /dev/null +++ b/tests/integration/repo_watch_test.go @@ -0,0 +1,24 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/url" + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" +) + +func TestRepoWatch(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + // Test round-trip auto-watch + setting.Service.AutoWatchOnChanges = true + session := loginUser(t, "user2") + unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: 2, RepoID: 3}) + testEditFile(t, session, "org3", "repo3", "master", "README.md", "Hello, World (Edited for watch)\n") + unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 2, RepoID: 3, Mode: repo_model.WatchModeAuto}) + }) +} diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go new file mode 100644 index 0000000..8f65b5c --- /dev/null +++ b/tests/integration/repo_webhook_test.go @@ -0,0 +1,473 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + gitea_context "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/webhook" + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" +) + +func TestNewWebHookLink(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + + webhooksLen := len(webhook.List()) + baseurl := "/user2/repo1/settings/hooks" + tests := []string{ + // webhook list page + baseurl, + // new webhook page + baseurl + "/gitea/new", + // edit webhook page + baseurl + "/1", + } + + var csrfToken string + for _, url := range tests { + resp := session.MakeRequest(t, NewRequest(t, "GET", url), http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Equal(t, + webhooksLen, + htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(), + "not all webhooks are listed in the 'new' dropdown") + + csrfToken = htmlDoc.GetCSRF() + } + + // ensure that the "failure" pages has the full dropdown as well + resp := session.MakeRequest(t, NewRequestWithValues(t, "POST", baseurl+"/gitea/new", map[string]string{"_csrf": csrfToken}), http.StatusUnprocessableEntity) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Equal(t, + webhooksLen, + htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(), + "not all webhooks are listed in the 'new' dropdown on failure") + + resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", baseurl+"/1", map[string]string{"_csrf": csrfToken}), http.StatusUnprocessableEntity) + htmlDoc = NewHTMLParser(t, resp.Body) + assert.Equal(t, + webhooksLen, + htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(), + "not all webhooks are listed in the 'new' dropdown on failure") + + adminSession := loginUser(t, "user1") + t.Run("org3", func(t *testing.T) { + baseurl := "/org/org3/settings/hooks" + resp := adminSession.MakeRequest(t, NewRequest(t, "GET", baseurl), http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Equal(t, + webhooksLen, + htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(), + "not all webhooks are listed in the 'new' dropdown") + }) + t.Run("admin", func(t *testing.T) { + baseurl := "/admin/hooks" + resp := adminSession.MakeRequest(t, NewRequest(t, "GET", baseurl), http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Equal(t, + webhooksLen, + htmlDoc.Find(`a[href^="/admin/default-hooks/"][href$="/new"]`).Length(), + "not all webhooks are listed in the 'new' dropdown for default-hooks") + assert.Equal(t, + webhooksLen, + htmlDoc.Find(`a[href^="/admin/system-hooks/"][href$="/new"]`).Length(), + "not all webhooks are listed in the 'new' dropdown for system-hooks") + }) +} + +func TestWebhookForms(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + + t.Run("forgejo/required", testWebhookForms("forgejo", session, map[string]string{ + "payload_url": "https://forgejo.example.com", + "http_method": "POST", + "content_type": "1", // json + }, map[string]string{ + "payload_url": "", + }, map[string]string{ + "http_method": "", + }, map[string]string{ + "content_type": "", + }, map[string]string{ + "payload_url": "invalid_url", + }, map[string]string{ + "http_method": "INVALID", + })) + t.Run("forgejo/optional", testWebhookForms("forgejo", session, map[string]string{ + "payload_url": "https://forgejo.example.com", + "http_method": "POST", + "content_type": "1", // json + "secret": "s3cr3t", + + "branch_filter": "forgejo/*", + "authorization_header": "Bearer 123456", + })) + + t.Run("gitea/required", testWebhookForms("gitea", session, map[string]string{ + "payload_url": "https://gitea.example.com", + "http_method": "POST", + "content_type": "1", // json + }, map[string]string{ + "payload_url": "", + }, map[string]string{ + "http_method": "", + }, map[string]string{ + "content_type": "", + }, map[string]string{ + "payload_url": "invalid_url", + }, map[string]string{ + "http_method": "INVALID", + })) + t.Run("gitea/optional", testWebhookForms("gitea", session, map[string]string{ + "payload_url": "https://gitea.example.com", + "http_method": "POST", + "content_type": "1", // json + "secret": "s3cr3t", + + "branch_filter": "gitea/*", + "authorization_header": "Bearer 123456", + })) + + t.Run("gogs/required", testWebhookForms("gogs", session, map[string]string{ + "payload_url": "https://gogs.example.com", + "content_type": "1", // json + })) + t.Run("gogs/optional", testWebhookForms("gogs", session, map[string]string{ + "payload_url": "https://gogs.example.com", + "content_type": "1", // json + "secret": "s3cr3t", + + "branch_filter": "gogs/*", + "authorization_header": "Bearer 123456", + })) + + t.Run("slack/required", testWebhookForms("slack", session, map[string]string{ + "payload_url": "https://slack.example.com", + "channel": "general", + }, map[string]string{ + "channel": "", + }, map[string]string{ + "channel": "invalid channel name", + })) + t.Run("slack/optional", testWebhookForms("slack", session, map[string]string{ + "payload_url": "https://slack.example.com", + "channel": "#general", + "username": "john", + "icon_url": "https://slack.example.com/icon.png", + "color": "#dd4b39", + + "branch_filter": "slack/*", + "authorization_header": "Bearer 123456", + })) + + t.Run("discord/required", testWebhookForms("discord", session, map[string]string{ + "username": "john", + "payload_url": "https://discord.example.com", + })) + t.Run("discord/optional", testWebhookForms("discord", session, map[string]string{ + "payload_url": "https://discord.example.com", + "username": "john", + "icon_url": "https://discord.example.com/icon.png", + + "branch_filter": "discord/*", + "authorization_header": "Bearer 123456", + })) + + t.Run("dingtalk/required", testWebhookForms("dingtalk", session, map[string]string{ + "payload_url": "https://dingtalk.example.com", + })) + t.Run("dingtalk/optional", testWebhookForms("dingtalk", session, map[string]string{ + "payload_url": "https://dingtalk.example.com", + + "branch_filter": "discord/*", + "authorization_header": "Bearer 123456", + })) + + t.Run("telegram/required", testWebhookForms("telegram", session, map[string]string{ + "bot_token": "123456", + "chat_id": "789", + })) + t.Run("telegram/optional", testWebhookForms("telegram", session, map[string]string{ + "bot_token": "123456", + "chat_id": "789", + "thread_id": "abc", + + "branch_filter": "telegram/*", + "authorization_header": "Bearer 123456", + })) + + t.Run("msteams/required", testWebhookForms("msteams", session, map[string]string{ + "payload_url": "https://msteams.example.com", + })) + t.Run("msteams/optional", testWebhookForms("msteams", session, map[string]string{ + "payload_url": "https://msteams.example.com", + + "branch_filter": "msteams/*", + "authorization_header": "Bearer 123456", + })) + + t.Run("feishu/required", testWebhookForms("feishu", session, map[string]string{ + "payload_url": "https://feishu.example.com", + })) + t.Run("feishu/optional", testWebhookForms("feishu", session, map[string]string{ + "payload_url": "https://feishu.example.com", + + "branch_filter": "feishu/*", + "authorization_header": "Bearer 123456", + })) + + t.Run("matrix/required", testWebhookForms("matrix", session, map[string]string{ + "homeserver_url": "https://matrix.example.com", + "access_token": "123456", + "room_id": "123", + }, map[string]string{ + "access_token": "", + })) + t.Run("matrix/optional", testWebhookForms("matrix", session, map[string]string{ + "homeserver_url": "https://matrix.example.com", + "access_token": "123456", + "room_id": "123", + "message_type": "1", // m.text + + "branch_filter": "matrix/*", + })) + + t.Run("wechatwork/required", testWebhookForms("wechatwork", session, map[string]string{ + "payload_url": "https://wechatwork.example.com", + })) + t.Run("wechatwork/optional", testWebhookForms("wechatwork", session, map[string]string{ + "payload_url": "https://wechatwork.example.com", + + "branch_filter": "wechatwork/*", + "authorization_header": "Bearer 123456", + })) + + t.Run("packagist/required", testWebhookForms("packagist", session, map[string]string{ + "username": "john", + "api_token": "secret", + "package_url": "https://packagist.org/packages/example/framework", + })) + t.Run("packagist/optional", testWebhookForms("packagist", session, map[string]string{ + "username": "john", + "api_token": "secret", + "package_url": "https://packagist.org/packages/example/framework", + + "branch_filter": "packagist/*", + "authorization_header": "Bearer 123456", + })) + + t.Run("sourcehut_builds/required", testWebhookForms("sourcehut_builds", session, map[string]string{ + "payload_url": "https://sourcehut_builds.example.com", + "manifest_path": ".build.yml", + "visibility": "PRIVATE", + "access_token": "123456", + }, map[string]string{ + "access_token": "", + }, map[string]string{ + "manifest_path": "", + }, map[string]string{ + "manifest_path": "/absolute", + }, map[string]string{ + "visibility": "", + }, map[string]string{ + "visibility": "INVALID", + })) + t.Run("sourcehut_builds/optional", testWebhookForms("sourcehut_builds", session, map[string]string{ + "payload_url": "https://sourcehut_builds.example.com", + "manifest_path": ".build.yml", + "visibility": "PRIVATE", + "secrets": "on", + "access_token": "123456", + + "branch_filter": "srht/*", + })) +} + +func assertInput(t testing.TB, form *goquery.Selection, name string) string { + t.Helper() + input := form.Find(`input[name="` + name + `"]`) + if input.Length() != 1 { + form.Find("input").Each(func(i int, s *goquery.Selection) { + t.Logf("found <input name=%q />", s.AttrOr("name", "")) + }) + t.Errorf("field <input name=%q /> found %d times, expected once", name, input.Length()) + } + switch input.AttrOr("type", "") { + case "checkbox": + if _, checked := input.Attr("checked"); checked { + return "on" + } + return "" + default: + return input.AttrOr("value", "") + } +} + +func testWebhookForms(name string, session *TestSession, validFields map[string]string, invalidPatches ...map[string]string) func(t *testing.T) { + return func(t *testing.T) { + t.Run("repo1", func(t *testing.T) { + testWebhookFormsShared(t, "/user2/repo1/settings/hooks", name, session, validFields, invalidPatches...) + }) + t.Run("org3", func(t *testing.T) { + testWebhookFormsShared(t, "/org/org3/settings/hooks", name, session, validFields, invalidPatches...) + }) + t.Run("system", func(t *testing.T) { + testWebhookFormsShared(t, "/admin/system-hooks", name, session, validFields, invalidPatches...) + }) + t.Run("default", func(t *testing.T) { + testWebhookFormsShared(t, "/admin/default-hooks", name, session, validFields, invalidPatches...) + }) + } +} + +func testWebhookFormsShared(t *testing.T, endpoint, name string, session *TestSession, validFields map[string]string, invalidPatches ...map[string]string) { + // new webhook form + resp := session.MakeRequest(t, NewRequest(t, "GET", endpoint+"/"+name+"/new"), http.StatusOK) + htmlForm := NewHTMLParser(t, resp.Body).Find(`form[action^="` + endpoint + `/"]`) + + // fill the form + payload := map[string]string{ + "_csrf": htmlForm.Find(`input[name="_csrf"]`).AttrOr("value", ""), + "events": "send_everything", + } + for k, v := range validFields { + assertInput(t, htmlForm, k) + payload[k] = v + } + if t.Failed() { + t.FailNow() // prevent further execution if the form could not be filled properly + } + + // create the webhook (this redirects back to the hook list) + resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", endpoint+"/"+name+"/new", payload), http.StatusSeeOther) + assertHasFlashMessages(t, resp, "success") + listEndpoint := resp.Header().Get("Location") + updateEndpoint := endpoint + "/" + if endpoint == "/admin/system-hooks" || endpoint == "/admin/default-hooks" { + updateEndpoint = "/admin/hooks/" + } + + // find last created hook in the hook list + // (a bit hacky, but the list should be sorted) + resp = session.MakeRequest(t, NewRequest(t, "GET", listEndpoint), http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + selector := `a[href^="` + updateEndpoint + `"]` + if endpoint == "/admin/system-hooks" { + // system-hooks and default-hooks are listed on the same page + // add a specifier to select the latest system-hooks + // (the default-hooks are at the end, so no further specifier needed) + selector = `.admin-setting-content > div:first-of-type ` + selector + } + editFormURL := htmlDoc.Find(selector).Last().AttrOr("href", "") + assert.NotEmpty(t, editFormURL) + + // edit webhook form + resp = session.MakeRequest(t, NewRequest(t, "GET", editFormURL), http.StatusOK) + htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="` + updateEndpoint + `"]`) + editPostURL := htmlForm.AttrOr("action", "") + assert.NotEmpty(t, editPostURL) + + // fill the form + payload = map[string]string{ + "_csrf": htmlForm.Find(`input[name="_csrf"]`).AttrOr("value", ""), + "events": "push_only", + } + for k, v := range validFields { + assert.Equal(t, v, assertInput(t, htmlForm, k), "input %q did not contain value %q", k, v) + payload[k] = v + } + + // update the webhook + resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", editPostURL, payload), http.StatusSeeOther) + assertHasFlashMessages(t, resp, "success") + + // check the updated webhook + resp = session.MakeRequest(t, NewRequest(t, "GET", editFormURL), http.StatusOK) + htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="` + updateEndpoint + `"]`) + for k, v := range validFields { + assert.Equal(t, v, assertInput(t, htmlForm, k), "input %q did not contain value %q", k, v) + } + + if len(invalidPatches) > 0 { + // check that invalid fields are rejected + resp := session.MakeRequest(t, NewRequest(t, "GET", endpoint+"/"+name+"/new"), http.StatusOK) + htmlForm := NewHTMLParser(t, resp.Body).Find(`form[action^="` + endpoint + `/"]`) + + for _, invalidPatch := range invalidPatches { + t.Run("invalid", func(t *testing.T) { + // fill the form + payload := map[string]string{ + "_csrf": htmlForm.Find(`input[name="_csrf"]`).AttrOr("value", ""), + "events": "send_everything", + } + for k, v := range validFields { + payload[k] = v + } + for k, v := range invalidPatch { + if v == "" { + delete(payload, k) + } else { + payload[k] = v + } + } + + resp := session.MakeRequest(t, NewRequestWithValues(t, "POST", endpoint+"/"+name+"/new", payload), http.StatusUnprocessableEntity) + // check that the invalid form is pre-filled + htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="` + endpoint + `/"]`) + for k, v := range payload { + if k == "_csrf" || k == "events" || v == "" { + // the 'events' is a radio input, which is buggy below + continue + } + assert.Equal(t, v, assertInput(t, htmlForm, k), "input %q did not contain value %q", k, v) + } + if t.Failed() { + t.Log(invalidPatch) + } + }) + } + } +} + +func assertHasFlashMessages(t *testing.T, resp *httptest.ResponseRecorder, expectedKeys ...string) { + seenKeys := make(map[string][]string, len(expectedKeys)) + + for _, cookie := range resp.Result().Cookies() { + if cookie.Name != gitea_context.CookieNameFlash { + continue + } + flash, _ := url.ParseQuery(cookie.Value) + for key, value := range flash { + // the key is itself url-encoded + if flash, err := url.ParseQuery(key); err == nil { + for key, value := range flash { + seenKeys[key] = value + } + } else { + seenKeys[key] = value + } + } + } + + for _, k := range expectedKeys { + if len(seenKeys[k]) == 0 { + t.Errorf("missing expected flash message %q", k) + } + delete(seenKeys, k) + } + + for k, v := range seenKeys { + t.Errorf("unexpected flash message %q: %q", k, v) + } +} diff --git a/tests/integration/repo_wiki_test.go b/tests/integration/repo_wiki_test.go new file mode 100644 index 0000000..6115010 --- /dev/null +++ b/tests/integration/repo_wiki_test.go @@ -0,0 +1,91 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" +) + +func TestWikiSearchContent(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/user2/repo1/wiki/search?q=This") + resp := MakeRequest(t, req, http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + res := doc.Find(".item > b").Map(func(_ int, el *goquery.Selection) string { + return el.Text() + }) + assert.Equal(t, []string{ + "Home", + "Page With Spaced Name", + "Unescaped File", + }, res) +} + +func TestWikiBranchNormalize(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + username := "user2" + session := loginUser(t, username) + settingsURLStr := "/user2/repo1/settings" + + assertNormalizeButton := func(present bool) string { + req := NewRequest(t, "GET", settingsURLStr) //.AddTokenAuth(token) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, "button[data-modal='#rename-wiki-branch-modal']", present) + + return htmlDoc.GetCSRF() + } + + // By default the repo wiki branch is empty + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + assert.Empty(t, repo.WikiBranch) + + // This means we default to setting.Repository.DefaultBranch + assert.Equal(t, setting.Repository.DefaultBranch, repo.GetWikiBranchName()) + + // Which further means that the "Normalize wiki branch" parts do not appear on settings + assertNormalizeButton(false) + + // Lets rename the branch! + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + repoURLStr := fmt.Sprintf("/api/v1/repos/%s/%s", username, repo.Name) + wikiBranch := "wiki" + req := NewRequestWithJSON(t, "PATCH", repoURLStr, &api.EditRepoOption{ + WikiBranch: &wikiBranch, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + // The wiki branch should now be changed + repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + assert.Equal(t, wikiBranch, repo.GetWikiBranchName()) + + // And as such, the button appears! + csrf := assertNormalizeButton(true) + + // Invoking the normalization renames the wiki branch back to the default + req = NewRequestWithValues(t, "POST", settingsURLStr, map[string]string{ + "_csrf": csrf, + "action": "rename-wiki-branch", + "repo_name": repo.FullName(), + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + assert.Equal(t, setting.Repository.DefaultBranch, repo.GetWikiBranchName()) + assertNormalizeButton(false) +} diff --git a/tests/integration/repofiles_change_test.go b/tests/integration/repofiles_change_test.go new file mode 100644 index 0000000..9790b36 --- /dev/null +++ b/tests/integration/repofiles_change_test.go @@ -0,0 +1,495 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + files_service "code.gitea.io/gitea/services/repository/files" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func getCreateRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions { + return &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "new/file.txt", + ContentReader: strings.NewReader("This is a NEW file"), + }, + }, + OldBranch: repo.DefaultBranch, + NewBranch: repo.DefaultBranch, + Message: "Creates new/file.txt", + Author: nil, + Committer: nil, + } +} + +func getUpdateRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions { + return &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "update", + TreePath: "README.md", + SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f", + ContentReader: strings.NewReader("This is UPDATED content for the README file"), + }, + }, + OldBranch: repo.DefaultBranch, + NewBranch: repo.DefaultBranch, + Message: "Updates README.md", + Author: nil, + Committer: nil, + } +} + +func getDeleteRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions { + return &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "delete", + TreePath: "README.md", + SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f", + }, + }, + LastCommitID: "", + OldBranch: repo.DefaultBranch, + NewBranch: repo.DefaultBranch, + Message: "Deletes README.md", + Author: &files_service.IdentityOptions{ + Name: "Bob Smith", + Email: "bob@smith.com", + }, + Committer: nil, + } +} + +func getExpectedFileResponseForRepofilesDelete() *api.FileResponse { + // Just returns fields that don't change, i.e. fields with commit SHAs and dates can't be determined + return &api.FileResponse{ + Content: nil, + Commit: &api.FileCommitResponse{ + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "Bob Smith", + Email: "bob@smith.com", + }, + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "Bob Smith", + Email: "bob@smith.com", + }, + }, + Message: "Deletes README.md\n", + }, + Verification: &api.PayloadCommitVerification{ + Verified: false, + Reason: "gpg.error.not_signed_commit", + Signature: "", + Payload: "", + }, + } +} + +func getExpectedFileResponseForRepofilesCreate(commitID, lastCommitSHA string) *api.FileResponse { + treePath := "new/file.txt" + encoding := "base64" + content := "VGhpcyBpcyBhIE5FVyBmaWxl" + selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath + "?ref=master" + htmlURL := setting.AppURL + "user2/repo1/src/branch/master/" + treePath + gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/103ff9234cefeee5ec5361d22b49fbb04d385885" + downloadURL := setting.AppURL + "user2/repo1/raw/branch/master/" + treePath + return &api.FileResponse{ + Content: &api.ContentsResponse{ + Name: filepath.Base(treePath), + Path: treePath, + SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885", + LastCommitSHA: lastCommitSHA, + Type: "file", + Size: 18, + Encoding: &encoding, + Content: &content, + URL: &selfURL, + HTMLURL: &htmlURL, + GitURL: &gitURL, + DownloadURL: &downloadURL, + Links: &api.FileLinksResponse{ + Self: &selfURL, + GitURL: &gitURL, + HTMLURL: &htmlURL, + }, + }, + Commit: &api.FileCommitResponse{ + CommitMeta: api.CommitMeta{ + URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/" + commitID, + SHA: commitID, + }, + HTMLURL: setting.AppURL + "user2/repo1/commit/" + commitID, + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "User Two", + Email: "user2@noreply.example.org", + }, + Date: time.Now().UTC().Format(time.RFC3339), + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "User Two", + Email: "user2@noreply.example.org", + }, + Date: time.Now().UTC().Format(time.RFC3339), + }, + Parents: []*api.CommitMeta{ + { + URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/65f1bf27bc3bf70f64657658635e66094edbcb4d", + SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d", + }, + }, + Message: "Updates README.md\n", + Tree: &api.CommitMeta{ + URL: setting.AppURL + "api/v1/repos/user2/repo1/git/trees/f93e3a1a1525fb5b91020da86e44810c87a2d7bc", + SHA: "f93e3a1a1525fb5b91020git dda86e44810c87a2d7bc", + }, + }, + Verification: &api.PayloadCommitVerification{ + Verified: false, + Reason: "gpg.error.not_signed_commit", + Signature: "", + Payload: "", + }, + } +} + +func getExpectedFileResponseForRepofilesUpdate(commitID, filename, lastCommitSHA string) *api.FileResponse { + encoding := "base64" + content := "VGhpcyBpcyBVUERBVEVEIGNvbnRlbnQgZm9yIHRoZSBSRUFETUUgZmlsZQ==" + selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + filename + "?ref=master" + htmlURL := setting.AppURL + "user2/repo1/src/branch/master/" + filename + gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/dbf8d00e022e05b7e5cf7e535de857de57925647" + downloadURL := setting.AppURL + "user2/repo1/raw/branch/master/" + filename + return &api.FileResponse{ + Content: &api.ContentsResponse{ + Name: filename, + Path: filename, + SHA: "dbf8d00e022e05b7e5cf7e535de857de57925647", + LastCommitSHA: lastCommitSHA, + Type: "file", + Size: 43, + Encoding: &encoding, + Content: &content, + URL: &selfURL, + HTMLURL: &htmlURL, + GitURL: &gitURL, + DownloadURL: &downloadURL, + Links: &api.FileLinksResponse{ + Self: &selfURL, + GitURL: &gitURL, + HTMLURL: &htmlURL, + }, + }, + Commit: &api.FileCommitResponse{ + CommitMeta: api.CommitMeta{ + URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/" + commitID, + SHA: commitID, + }, + HTMLURL: setting.AppURL + "user2/repo1/commit/" + commitID, + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "User Two", + Email: "user2@noreply.example.org", + }, + Date: time.Now().UTC().Format(time.RFC3339), + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "User Two", + Email: "user2@noreply.example.org", + }, + Date: time.Now().UTC().Format(time.RFC3339), + }, + Parents: []*api.CommitMeta{ + { + URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/65f1bf27bc3bf70f64657658635e66094edbcb4d", + SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d", + }, + }, + Message: "Updates README.md\n", + Tree: &api.CommitMeta{ + URL: setting.AppURL + "api/v1/repos/user2/repo1/git/trees/f93e3a1a1525fb5b91020da86e44810c87a2d7bc", + SHA: "f93e3a1a1525fb5b91020da86e44810c87a2d7bc", + }, + }, + Verification: &api.PayloadCommitVerification{ + Verified: false, + Reason: "gpg.error.not_signed_commit", + Signature: "", + Payload: "", + }, + } +} + +func TestChangeRepoFilesForCreate(t *testing.T) { + // setup + onGiteaRun(t, func(t *testing.T, u *url.URL) { + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + opts := getCreateRepoFilesOptions(repo) + + // test + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) + + // asserts + require.NoError(t, err) + gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo) + defer gitRepo.Close() + + commitID, _ := gitRepo.GetBranchCommitID(opts.NewBranch) + lastCommit, _ := gitRepo.GetCommitByPath("new/file.txt") + expectedFileResponse := getExpectedFileResponseForRepofilesCreate(commitID, lastCommit.ID.String()) + assert.NotNil(t, expectedFileResponse) + if expectedFileResponse != nil { + assert.EqualValues(t, expectedFileResponse.Content, filesResponse.Files[0]) + assert.EqualValues(t, expectedFileResponse.Commit.SHA, filesResponse.Commit.SHA) + assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, filesResponse.Commit.HTMLURL) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, filesResponse.Commit.Author.Email) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, filesResponse.Commit.Author.Name) + } + }) +} + +func TestChangeRepoFilesForUpdate(t *testing.T) { + // setup + onGiteaRun(t, func(t *testing.T, u *url.URL) { + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + opts := getUpdateRepoFilesOptions(repo) + + // test + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) + + // asserts + require.NoError(t, err) + gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo) + defer gitRepo.Close() + + commit, _ := gitRepo.GetBranchCommit(opts.NewBranch) + lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath) + expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.Files[0].TreePath, lastCommit.ID.String()) + assert.EqualValues(t, expectedFileResponse.Content, filesResponse.Files[0]) + assert.EqualValues(t, expectedFileResponse.Commit.SHA, filesResponse.Commit.SHA) + assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, filesResponse.Commit.HTMLURL) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, filesResponse.Commit.Author.Email) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, filesResponse.Commit.Author.Name) + }) +} + +func TestChangeRepoFilesForUpdateWithFileMove(t *testing.T) { + // setup + onGiteaRun(t, func(t *testing.T, u *url.URL) { + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + opts := getUpdateRepoFilesOptions(repo) + opts.Files[0].FromTreePath = "README.md" + opts.Files[0].TreePath = "README_new.md" // new file name, README_new.md + + // test + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) + + // asserts + require.NoError(t, err) + gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo) + defer gitRepo.Close() + + commit, _ := gitRepo.GetBranchCommit(opts.NewBranch) + lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath) + expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.Files[0].TreePath, lastCommit.ID.String()) + // assert that the old file no longer exists in the last commit of the branch + fromEntry, err := commit.GetTreeEntryByPath(opts.Files[0].FromTreePath) + switch err.(type) { + case git.ErrNotExist: + // correct, continue + default: + t.Fatalf("expected git.ErrNotExist, got:%v", err) + } + toEntry, err := commit.GetTreeEntryByPath(opts.Files[0].TreePath) + require.NoError(t, err) + assert.Nil(t, fromEntry) // Should no longer exist here + assert.NotNil(t, toEntry) // Should exist here + // assert SHA has remained the same but paths use the new file name + assert.EqualValues(t, expectedFileResponse.Content.SHA, filesResponse.Files[0].SHA) + assert.EqualValues(t, expectedFileResponse.Content.Name, filesResponse.Files[0].Name) + assert.EqualValues(t, expectedFileResponse.Content.Path, filesResponse.Files[0].Path) + assert.EqualValues(t, expectedFileResponse.Content.URL, filesResponse.Files[0].URL) + assert.EqualValues(t, expectedFileResponse.Commit.SHA, filesResponse.Commit.SHA) + assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, filesResponse.Commit.HTMLURL) + }) +} + +// Test opts with branch names removed, should get same results as above test +func TestChangeRepoFilesWithoutBranchNames(t *testing.T) { + // setup + onGiteaRun(t, func(t *testing.T, u *url.URL) { + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + opts := getUpdateRepoFilesOptions(repo) + opts.OldBranch = "" + opts.NewBranch = "" + + // test + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) + + // asserts + require.NoError(t, err) + gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo) + defer gitRepo.Close() + + commit, _ := gitRepo.GetBranchCommit(repo.DefaultBranch) + lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath) + expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.Files[0].TreePath, lastCommit.ID.String()) + assert.EqualValues(t, expectedFileResponse.Content, filesResponse.Files[0]) + }) +} + +func TestChangeRepoFilesForDelete(t *testing.T) { + onGiteaRun(t, testDeleteRepoFiles) +} + +func testDeleteRepoFiles(t *testing.T, u *url.URL) { + // setup + unittest.PrepareTestEnv(t) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + opts := getDeleteRepoFilesOptions(repo) + + t.Run("Delete README.md file", func(t *testing.T) { + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) + require.NoError(t, err) + expectedFileResponse := getExpectedFileResponseForRepofilesDelete() + assert.NotNil(t, filesResponse) + assert.Nil(t, filesResponse.Files[0]) + assert.EqualValues(t, expectedFileResponse.Commit.Message, filesResponse.Commit.Message) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Identity, filesResponse.Commit.Author.Identity) + assert.EqualValues(t, expectedFileResponse.Commit.Committer.Identity, filesResponse.Commit.Committer.Identity) + assert.EqualValues(t, expectedFileResponse.Verification, filesResponse.Verification) + }) + + t.Run("Verify README.md has been deleted", func(t *testing.T) { + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) + assert.Nil(t, filesResponse) + expectedError := "repository file does not exist [path: " + opts.Files[0].TreePath + "]" + assert.EqualError(t, err, expectedError) + }) +} + +// Test opts with branch names removed, same results +func TestChangeRepoFilesForDeleteWithoutBranchNames(t *testing.T) { + onGiteaRun(t, testDeleteRepoFilesWithoutBranchNames) +} + +func testDeleteRepoFilesWithoutBranchNames(t *testing.T, u *url.URL) { + // setup + unittest.PrepareTestEnv(t) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + opts := getDeleteRepoFilesOptions(repo) + opts.OldBranch = "" + opts.NewBranch = "" + + t.Run("Delete README.md without Branch Name", func(t *testing.T) { + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) + require.NoError(t, err) + expectedFileResponse := getExpectedFileResponseForRepofilesDelete() + assert.NotNil(t, filesResponse) + assert.Nil(t, filesResponse.Files[0]) + assert.EqualValues(t, expectedFileResponse.Commit.Message, filesResponse.Commit.Message) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Identity, filesResponse.Commit.Author.Identity) + assert.EqualValues(t, expectedFileResponse.Commit.Committer.Identity, filesResponse.Commit.Committer.Identity) + assert.EqualValues(t, expectedFileResponse.Verification, filesResponse.Verification) + }) +} + +func TestChangeRepoFilesErrors(t *testing.T) { + // setup + onGiteaRun(t, func(t *testing.T, u *url.URL) { + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + t.Run("bad branch", func(t *testing.T) { + opts := getUpdateRepoFilesOptions(repo) + opts.OldBranch = "bad_branch" + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) + require.Error(t, err) + assert.Nil(t, filesResponse) + expectedError := "branch does not exist [name: " + opts.OldBranch + "]" + assert.EqualError(t, err, expectedError) + }) + + t.Run("bad SHA", func(t *testing.T) { + opts := getUpdateRepoFilesOptions(repo) + origSHA := opts.Files[0].SHA + opts.Files[0].SHA = "bad_sha" + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) + assert.Nil(t, filesResponse) + require.Error(t, err) + expectedError := "sha does not match [given: " + opts.Files[0].SHA + ", expected: " + origSHA + "]" + assert.EqualError(t, err, expectedError) + }) + + t.Run("new branch already exists", func(t *testing.T) { + opts := getUpdateRepoFilesOptions(repo) + opts.NewBranch = "develop" + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) + assert.Nil(t, filesResponse) + require.Error(t, err) + expectedError := "branch already exists [name: " + opts.NewBranch + "]" + assert.EqualError(t, err, expectedError) + }) + + t.Run("treePath is empty:", func(t *testing.T) { + opts := getUpdateRepoFilesOptions(repo) + opts.Files[0].TreePath = "" + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) + assert.Nil(t, filesResponse) + require.Error(t, err) + expectedError := "path contains a malformed path component [path: ]" + assert.EqualError(t, err, expectedError) + }) + + t.Run("treePath is a git directory:", func(t *testing.T) { + opts := getUpdateRepoFilesOptions(repo) + opts.Files[0].TreePath = ".git" + filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) + assert.Nil(t, filesResponse) + require.Error(t, err) + expectedError := "path contains a malformed path component [path: " + opts.Files[0].TreePath + "]" + assert.EqualError(t, err, expectedError) + }) + + t.Run("create file that already exists", func(t *testing.T) { + opts := getCreateRepoFilesOptions(repo) + opts.Files[0].TreePath = "README.md" // already exists + fileResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts) + assert.Nil(t, fileResponse) + require.Error(t, err) + expectedError := "repository file already exists [path: " + opts.Files[0].TreePath + "]" + assert.EqualError(t, err, expectedError) + }) + }) +} diff --git a/tests/integration/schemas/nodeinfo_2.1.json b/tests/integration/schemas/nodeinfo_2.1.json new file mode 100644 index 0000000..561e644 --- /dev/null +++ b/tests/integration/schemas/nodeinfo_2.1.json @@ -0,0 +1,188 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "http://nodeinfo.diaspora.software/ns/schema/2.1#", + "description": "NodeInfo schema version 2.1.", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "software", + "protocols", + "services", + "openRegistrations", + "usage", + "metadata" + ], + "properties": { + "version": { + "description": "The schema version, must be 2.1.", + "enum": [ + "2.1" + ] + }, + "software": { + "description": "Metadata about server software in use.", + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "version" + ], + "properties": { + "name": { + "description": "The canonical name of this server software.", + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "version": { + "description": "The version of this server software.", + "type": "string" + }, + "repository": { + "description": "The url of the source code repository of this server software.", + "type": "string" + }, + "homepage": { + "description": "The url of the homepage of this server software.", + "type": "string" + } + } + }, + "protocols": { + "description": "The protocols supported on this server.", + "type": "array", + "minItems": 1, + "items": { + "enum": [ + "activitypub", + "buddycloud", + "dfrn", + "diaspora", + "libertree", + "ostatus", + "pumpio", + "tent", + "xmpp", + "zot" + ] + } + }, + "services": { + "description": "The third party sites this server can connect to via their application API.", + "type": "object", + "additionalProperties": false, + "required": [ + "inbound", + "outbound" + ], + "properties": { + "inbound": { + "description": "The third party sites this server can retrieve messages from for combined display with regular traffic.", + "type": "array", + "minItems": 0, + "items": { + "enum": [ + "atom1.0", + "gnusocial", + "imap", + "pnut", + "pop3", + "pumpio", + "rss2.0", + "twitter" + ] + } + }, + "outbound": { + "description": "The third party sites this server can publish messages to on the behalf of a user.", + "type": "array", + "minItems": 0, + "items": { + "enum": [ + "atom1.0", + "blogger", + "buddycloud", + "diaspora", + "dreamwidth", + "drupal", + "facebook", + "friendica", + "gnusocial", + "google", + "insanejournal", + "libertree", + "linkedin", + "livejournal", + "mediagoblin", + "myspace", + "pinterest", + "pnut", + "posterous", + "pumpio", + "redmatrix", + "rss2.0", + "smtp", + "tent", + "tumblr", + "twitter", + "wordpress", + "xmpp" + ] + } + } + } + }, + "openRegistrations": { + "description": "Whether this server allows open self-registration.", + "type": "boolean" + }, + "usage": { + "description": "Usage statistics for this server.", + "type": "object", + "additionalProperties": false, + "required": [ + "users" + ], + "properties": { + "users": { + "description": "statistics about the users of this server.", + "type": "object", + "additionalProperties": false, + "properties": { + "total": { + "description": "The total amount of on this server registered users.", + "type": "integer", + "minimum": 0 + }, + "activeHalfyear": { + "description": "The amount of users that signed in at least once in the last 180 days.", + "type": "integer", + "minimum": 0 + }, + "activeMonth": { + "description": "The amount of users that signed in at least once in the last 30 days.", + "type": "integer", + "minimum": 0 + } + } + }, + "localPosts": { + "description": "The amount of posts that were made by users that are registered on this server.", + "type": "integer", + "minimum": 0 + }, + "localComments": { + "description": "The amount of comments that were made by users that are registered on this server.", + "type": "integer", + "minimum": 0 + } + } + }, + "metadata": { + "description": "Free form key value pairs for software specific values. Clients should not rely on any specific key present.", + "type": "object", + "minProperties": 0, + "additionalProperties": true + } + } +} diff --git a/tests/integration/session_test.go b/tests/integration/session_test.go new file mode 100644 index 0000000..a5bcab2 --- /dev/null +++ b/tests/integration/session_test.go @@ -0,0 +1,38 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "testing" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_RegenerateSession(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + require.NoError(t, unittest.PrepareTestDatabase()) + + key := "new_key890123456" // it must be 16 characters long + key2 := "new_key890123457" // it must be 16 characters + exist, err := auth.ExistSession(db.DefaultContext, key) + require.NoError(t, err) + assert.False(t, exist) + + sess, err := auth.RegenerateSession(db.DefaultContext, "", key) + require.NoError(t, err) + assert.EqualValues(t, key, sess.Key) + assert.Empty(t, sess.Data, 0) + + sess, err = auth.ReadSession(db.DefaultContext, key2) + require.NoError(t, err) + assert.EqualValues(t, key2, sess.Key) + assert.Empty(t, sess.Data, 0) +} diff --git a/tests/integration/setting_test.go b/tests/integration/setting_test.go new file mode 100644 index 0000000..29615b3 --- /dev/null +++ b/tests/integration/setting_test.go @@ -0,0 +1,158 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSettingShowUserEmailExplore(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + showUserEmail := setting.UI.ShowUserEmail + setting.UI.ShowUserEmail = true + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/explore/users?sort=alphabetically") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, + htmlDoc.doc.Find(".explore.users").Text(), + "user34@example.com", + ) + + setting.UI.ShowUserEmail = false + + req = NewRequest(t, "GET", "/explore/users?sort=alphabetically") + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + assert.NotContains(t, + htmlDoc.doc.Find(".explore.users").Text(), + "user34@example.com", + ) + + setting.UI.ShowUserEmail = showUserEmail +} + +func TestSettingShowUserEmailProfile(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + showUserEmail := setting.UI.ShowUserEmail + + // user1: keep_email_private = false, user2: keep_email_private = true + + setting.UI.ShowUserEmail = true + + // user1 can see own visible email + session := loginUser(t, "user1") + req := NewRequest(t, "GET", "/user1") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, htmlDoc.doc.Find(".user.profile").Text(), "user1@example.com") + + // user1 can not see user2's hidden email + req = NewRequest(t, "GET", "/user2") + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + // Should only contain if the user visits their own profile page + assert.NotContains(t, htmlDoc.doc.Find(".user.profile").Text(), "user2@example.com") + + // user2 can see user1's visible email + session = loginUser(t, "user2") + req = NewRequest(t, "GET", "/user1") + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + assert.Contains(t, htmlDoc.doc.Find(".user.profile").Text(), "user1@example.com") + + // user2 cannot see own hidden email + session = loginUser(t, "user2") + req = NewRequest(t, "GET", "/user2") + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + assert.NotContains(t, htmlDoc.doc.Find(".user.profile").Text(), "user2@example.com") + + setting.UI.ShowUserEmail = false + + // user1 cannot see own (now hidden) email + session = loginUser(t, "user1") + req = NewRequest(t, "GET", "/user1") + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + assert.NotContains(t, htmlDoc.doc.Find(".user.profile").Text(), "user1@example.com") + + setting.UI.ShowUserEmail = showUserEmail +} + +func TestSettingLandingPage(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + landingPage := setting.LandingPageURL + + setting.LandingPageURL = setting.LandingPageHome + req := NewRequest(t, "GET", "/") + MakeRequest(t, req, http.StatusOK) + + setting.LandingPageURL = setting.LandingPageExplore + req = NewRequest(t, "GET", "/") + resp := MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/explore", resp.Header().Get("Location")) + + setting.LandingPageURL = setting.LandingPageOrganizations + req = NewRequest(t, "GET", "/") + resp = MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/explore/organizations", resp.Header().Get("Location")) + + setting.LandingPageURL = setting.LandingPageLogin + req = NewRequest(t, "GET", "/") + resp = MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/user/login", resp.Header().Get("Location")) + + setting.LandingPageURL = landingPage +} + +func TestSettingSecurityAuthSource(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + active := addAuthSource(t, authSourcePayloadGitLabCustom("gitlab-active")) + activeExternalLoginUser := &user_model.ExternalLoginUser{ + ExternalID: "12345", + UserID: user.ID, + LoginSourceID: active.ID, + } + err := user_model.LinkExternalToUser(db.DefaultContext, user, activeExternalLoginUser) + require.NoError(t, err) + + inactive := addAuthSource(t, authSourcePayloadGitLabCustom("gitlab-inactive")) + inactiveExternalLoginUser := &user_model.ExternalLoginUser{ + ExternalID: "5678", + UserID: user.ID, + LoginSourceID: inactive.ID, + } + err = user_model.LinkExternalToUser(db.DefaultContext, user, inactiveExternalLoginUser) + require.NoError(t, err) + + // mark the authSource as inactive + inactive.IsActive = false + err = auth_model.UpdateSource(db.DefaultContext, inactive) + require.NoError(t, err) + + session := loginUser(t, "user1") + req := NewRequest(t, "GET", "user/settings/security") + resp := session.MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), `gitlab-active`) + assert.Contains(t, resp.Body.String(), `gitlab-inactive`) +} diff --git a/tests/integration/signin_test.go b/tests/integration/signin_test.go new file mode 100644 index 0000000..77e19bb --- /dev/null +++ b/tests/integration/signin_test.go @@ -0,0 +1,95 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "strings" + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func testLoginFailed(t *testing.T, username, password, message string) { + session := emptyTestSession(t) + req := NewRequestWithValues(t, "POST", "/user/login", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/login"), + "user_name": username, + "password": password, + }) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + resultMsg := htmlDoc.doc.Find(".ui.message>p").Text() + + assert.EqualValues(t, message, resultMsg) +} + +func TestSignin(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // add new user with user2's email + user.Name = "testuser" + user.LowerName = strings.ToLower(user.Name) + user.ID = 0 + unittest.AssertSuccessfulInsert(t, user) + + samples := []struct { + username string + password string + message string + }{ + {username: "wrongUsername", password: "wrongPassword", message: translation.NewLocale("en-US").TrString("form.username_password_incorrect")}, + {username: "wrongUsername", password: "password", message: translation.NewLocale("en-US").TrString("form.username_password_incorrect")}, + {username: "user15", password: "wrongPassword", message: translation.NewLocale("en-US").TrString("form.username_password_incorrect")}, + {username: "user1@example.com", password: "wrongPassword", message: translation.NewLocale("en-US").TrString("form.username_password_incorrect")}, + } + + for _, s := range samples { + testLoginFailed(t, s.username, s.password, s.message) + } +} + +func TestSigninWithRememberMe(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + baseURL, _ := url.Parse(setting.AppURL) + + session := emptyTestSession(t) + req := NewRequestWithValues(t, "POST", "/user/login", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/login"), + "user_name": user.Name, + "password": userPassword, + "remember": "on", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + c := session.GetCookie(setting.CookieRememberName) + assert.NotNil(t, c) + + session = emptyTestSession(t) + + // Without session the settings page should not be reachable + req = NewRequest(t, "GET", "/user/settings") + session.MakeRequest(t, req, http.StatusSeeOther) + + req = NewRequest(t, "GET", "/user/login") + // Set the remember me cookie for the login GET request + session.jar.SetCookies(baseURL, []*http.Cookie{c}) + session.MakeRequest(t, req, http.StatusSeeOther) + + // With session the settings page should be reachable + req = NewRequest(t, "GET", "/user/settings") + session.MakeRequest(t, req, http.StatusOK) +} diff --git a/tests/integration/signout_test.go b/tests/integration/signout_test.go new file mode 100644 index 0000000..7fd0b5c --- /dev/null +++ b/tests/integration/signout_test.go @@ -0,0 +1,24 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/tests" +) + +func TestSignOut(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + req := NewRequest(t, "POST", "/user/logout") + session.MakeRequest(t, req, http.StatusOK) + + // try to view a private repo, should fail + req = NewRequest(t, "GET", "/user2/repo2") + session.MakeRequest(t, req, http.StatusNotFound) +} diff --git a/tests/integration/signup_test.go b/tests/integration/signup_test.go new file mode 100644 index 0000000..d5df41f --- /dev/null +++ b/tests/integration/signup_test.go @@ -0,0 +1,209 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestSignup(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + setting.Service.EnableCaptcha = false + + req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{ + "user_name": "exampleUser", + "email": "exampleUser@example.com", + "password": "examplePassword!1", + "retype": "examplePassword!1", + }) + MakeRequest(t, req, http.StatusSeeOther) + + // should be able to view new user's page + req = NewRequest(t, "GET", "/exampleUser") + MakeRequest(t, req, http.StatusOK) +} + +func TestSignupAsRestricted(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + setting.Service.EnableCaptcha = false + setting.Service.DefaultUserIsRestricted = true + + req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{ + "user_name": "restrictedUser", + "email": "restrictedUser@example.com", + "password": "examplePassword!1", + "retype": "examplePassword!1", + }) + MakeRequest(t, req, http.StatusSeeOther) + + // should be able to view new user's page + req = NewRequest(t, "GET", "/restrictedUser") + MakeRequest(t, req, http.StatusOK) + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "restrictedUser"}) + assert.True(t, user2.IsRestricted) +} + +func TestSignupEmail(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + setting.Service.EnableCaptcha = false + + tests := []struct { + email string + wantStatus int + wantMsg string + }{ + {"exampleUser@example.com\r\n", http.StatusOK, translation.NewLocale("en-US").TrString("form.email_invalid")}, + {"exampleUser@example.com\r", http.StatusOK, translation.NewLocale("en-US").TrString("form.email_invalid")}, + {"exampleUser@example.com\n", http.StatusOK, translation.NewLocale("en-US").TrString("form.email_invalid")}, + {"exampleUser@example.com", http.StatusSeeOther, ""}, + } + + for i, test := range tests { + req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{ + "user_name": fmt.Sprintf("exampleUser%d", i), + "email": test.email, + "password": "examplePassword!1", + "retype": "examplePassword!1", + }) + resp := MakeRequest(t, req, test.wantStatus) + if test.wantMsg != "" { + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Equal(t, + test.wantMsg, + strings.TrimSpace(htmlDoc.doc.Find(".ui.message").Text()), + ) + } + } +} + +func TestSignupEmailChangeForInactiveUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // Disable the captcha & enable email confirmation for registrations + defer test.MockVariableValue(&setting.Service.EnableCaptcha, false)() + defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm, true)() + + // Create user + req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{ + "user_name": "exampleUserX", + "email": "wrong-email@example.com", + "password": "examplePassword!1", + "retype": "examplePassword!1", + }) + MakeRequest(t, req, http.StatusOK) + + session := loginUserWithPassword(t, "exampleUserX", "examplePassword!1") + + // Verify that the initial e-mail is the wrong one. + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserX"}) + assert.Equal(t, "wrong-email@example.com", user.Email) + + // Change the email address + req = NewRequestWithValues(t, "POST", "/user/activate", map[string]string{ + "email": "fine-email@example.com", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + // Verify that the email was updated + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserX"}) + assert.Equal(t, "fine-email@example.com", user.Email) + + // Try to change the email again + req = NewRequestWithValues(t, "POST", "/user/activate", map[string]string{ + "email": "wrong-again@example.com", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + // Verify that the email was NOT updated + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserX"}) + assert.Equal(t, "fine-email@example.com", user.Email) +} + +func TestSignupEmailChangeForActiveUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // Disable the captcha & enable email confirmation for registrations + defer test.MockVariableValue(&setting.Service.EnableCaptcha, false)() + defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm, false)() + + // Create user + req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{ + "user_name": "exampleUserY", + "email": "wrong-email-2@example.com", + "password": "examplePassword!1", + "retype": "examplePassword!1", + }) + MakeRequest(t, req, http.StatusSeeOther) + + session := loginUserWithPassword(t, "exampleUserY", "examplePassword!1") + + // Verify that the initial e-mail is the wrong one. + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserY"}) + assert.Equal(t, "wrong-email-2@example.com", user.Email) + + // Changing the email for a validated address is not available + req = NewRequestWithValues(t, "POST", "/user/activate", map[string]string{ + "email": "fine-email-2@example.com", + }) + session.MakeRequest(t, req, http.StatusNotFound) + + // Verify that the email remained unchanged + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserY"}) + assert.Equal(t, "wrong-email-2@example.com", user.Email) +} + +func TestSignupImageCaptcha(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm, false)() + defer test.MockVariableValue(&setting.Service.EnableCaptcha, true)() + defer test.MockVariableValue(&setting.Service.CaptchaType, "image")() + c := cache.GetCache() + + req := NewRequest(t, "GET", "/user/sign_up") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + idCaptcha, ok := htmlDoc.Find("input[name='img-captcha-id']").Attr("value") + assert.True(t, ok) + + digits, ok := c.Get("captcha:" + idCaptcha).(string) + assert.True(t, ok) + assert.Len(t, digits, 6) + + digitStr := "" + // Convert digits to ASCII digits. + for _, digit := range digits { + digitStr += string(digit + '0') + } + + req = NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{ + "user_name": "captcha-test", + "email": "captcha-test@example.com", + "password": "examplePassword!1", + "retype": "examplePassword!1", + "img-captcha-id": idCaptcha, + "img-captcha-response": digitStr, + }) + MakeRequest(t, req, http.StatusSeeOther) + + loginUserWithPassword(t, "captcha-test", "examplePassword!1") + + unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "captcha-test", IsActive: true}) +} diff --git a/tests/integration/size_translations_test.go b/tests/integration/size_translations_test.go new file mode 100644 index 0000000..a0b8829 --- /dev/null +++ b/tests/integration/size_translations_test.go @@ -0,0 +1,116 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "path" + "regexp" + "strings" + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" +) + +func TestDataSizeTranslation(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + testUser := "user2" + testRepoName := "data_size_test" + noDigits := regexp.MustCompile("[0-9]+") + longString100 := `testRepoMigrate(t, session, "https://code.forgejo.org/forgejo/test_repo.git", testRepoName, struct)` + "\n" + + // Login user + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: testUser}) + session := loginUser(t, testUser) + + // Create test repo + testRepo, _, f := tests.CreateDeclarativeRepo(t, user2, testRepoName, nil, nil, + []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "137byteFile.txt", + ContentReader: strings.NewReader(longString100 + strings.Repeat("1", 36) + "\n"), + }, + { + Operation: "create", + TreePath: "1.5kibFile.txt", + ContentReader: strings.NewReader(strings.Repeat(longString100, 15) + strings.Repeat("1", 35) + "\n"), + }, + { + Operation: "create", + TreePath: "1.25mibFile.txt", + ContentReader: strings.NewReader(strings.Repeat(longString100, 13107) + strings.Repeat("1", 19) + "\n"), + }, + }) + defer f() + + // Change language from English to catch regressions that make translated sizes fall back to + // not translated, like to raw output of FileSize() or humanize.IBytes() + lang := session.GetCookie("lang") + lang.Value = "ru-RU" + session.SetCookie(lang) + + // Go to /user/settings/repos + req := NewRequest(t, "GET", "user/settings/repos") + resp := session.MakeRequest(t, req, http.StatusOK) + + // Check if repo size is translated + repos := NewHTMLParser(t, resp.Body).Find(".user-setting-content .list .item .content") + assert.Positive(t, repos.Length()) + repos.Each(func(i int, repo *goquery.Selection) { + repoName := repo.Find("a.name").Text() + if repoName == path.Join(testUser, testRepo.Name) { + repoSize := repo.Find("span").Text() + repoSize = noDigits.ReplaceAllString(repoSize, "") + assert.Equal(t, " КиБ", repoSize) + } + }) + + // Go to /user2/repo1 + req = NewRequest(t, "GET", path.Join(testUser, testRepoName)) + resp = session.MakeRequest(t, req, http.StatusOK) + + // Check if repo size in repo summary is translated + repo := NewHTMLParser(t, resp.Body).Find(".repository-summary span") + repoSize := strings.TrimSpace(repo.Text()) + repoSize = noDigits.ReplaceAllString(repoSize, "") + assert.Equal(t, " КиБ", repoSize) + + // Check if repo sizes in the tooltip are translated + fullSize, exists := repo.Attr("data-tooltip-content") + assert.True(t, exists) + fullSize = noDigits.ReplaceAllString(fullSize, "") + assert.Equal(t, "git: КиБ; lfs: Б", fullSize) + + // Check if file sizes are correctly translated + testFileSizeTranslated(t, session, path.Join(testUser, testRepoName, "src/branch/main/137byteFile.txt"), "137 Б") + testFileSizeTranslated(t, session, path.Join(testUser, testRepoName, "src/branch/main/1.5kibFile.txt"), "1,5 КиБ") + testFileSizeTranslated(t, session, path.Join(testUser, testRepoName, "src/branch/main/1.25mibFile.txt"), "1,3 МиБ") + }) +} + +func testFileSizeTranslated(t *testing.T, session *TestSession, filePath, correctSize string) { + // Go to specified file page + req := NewRequest(t, "GET", filePath) + resp := session.MakeRequest(t, req, http.StatusOK) + + // Check if file size is translated + sizeCorrent := false + fileInfo := NewHTMLParser(t, resp.Body).Find(".file-info .file-info-entry") + fileInfo.Each(func(i int, info *goquery.Selection) { + infoText := strings.TrimSpace(info.Text()) + if infoText == correctSize { + sizeCorrent = true + } + }) + + assert.True(t, sizeCorrent) +} diff --git a/tests/integration/ssh_key_test.go b/tests/integration/ssh_key_test.go new file mode 100644 index 0000000..ebf0d26 --- /dev/null +++ b/tests/integration/ssh_key_test.go @@ -0,0 +1,208 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/git" + api "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func doCheckRepositoryEmptyStatus(ctx APITestContext, isEmpty bool) func(*testing.T) { + return doAPIGetRepository(ctx, func(t *testing.T, repository api.Repository) { + assert.Equal(t, isEmpty, repository.Empty) + }) +} + +func doAddChangesToCheckout(dstPath, filename string) func(*testing.T) { + return func(t *testing.T) { + require.NoError(t, os.WriteFile(filepath.Join(dstPath, filename), []byte(fmt.Sprintf("# Testing Repository\n\nOriginally created in: %s at time: %v", dstPath, time.Now())), 0o644)) + require.NoError(t, git.AddChanges(dstPath, true)) + signature := git.Signature{ + Email: "test@example.com", + Name: "test", + When: time.Now(), + } + require.NoError(t, git.CommitChanges(dstPath, git.CommitChangesOptions{ + Committer: &signature, + Author: &signature, + Message: "Initial Commit", + })) + } +} + +func TestPushDeployKeyOnEmptyRepo(t *testing.T) { + onGiteaRun(t, testPushDeployKeyOnEmptyRepo) +} + +func testPushDeployKeyOnEmptyRepo(t *testing.T, u *url.URL) { + forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) { + // OK login + ctx := NewAPITestContext(t, "user2", "deploy-key-empty-repo-"+objectFormat.Name(), auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + keyname := fmt.Sprintf("%s-push", ctx.Reponame) + u.Path = ctx.GitPath() + + t.Run("CreateEmptyRepository", doAPICreateRepository(ctx, true, objectFormat)) + + t.Run("CheckIsEmpty", doCheckRepositoryEmptyStatus(ctx, true)) + + withKeyFile(t, keyname, func(keyFile string) { + t.Run("CreatePushDeployKey", doAPICreateDeployKey(ctx, keyname, keyFile, false)) + + // Setup the testing repository + dstPath := t.TempDir() + + t.Run("InitTestRepository", doGitInitTestRepository(dstPath, objectFormat)) + + // Setup remote link + sshURL := createSSHUrl(ctx.GitPath(), u) + + t.Run("AddRemote", doGitAddRemote(dstPath, "origin", sshURL)) + + t.Run("SSHPushTestRepository", doGitPushTestRepository(dstPath, "origin", "master")) + + t.Run("CheckIsNotEmpty", doCheckRepositoryEmptyStatus(ctx, false)) + + t.Run("DeleteRepository", doAPIDeleteRepository(ctx)) + }) + }) +} + +func TestKeyOnlyOneType(t *testing.T) { + onGiteaRun(t, testKeyOnlyOneType) +} + +func testKeyOnlyOneType(t *testing.T, u *url.URL) { + // Once a key is a user key we cannot use it as a deploy key + // If we delete it from the user we should be able to use it as a deploy key + reponame := "ssh-key-test-repo" + username := "user2" + u.Path = fmt.Sprintf("%s/%s.git", username, reponame) + keyname := fmt.Sprintf("%s-push", reponame) + + // OK login + ctx := NewAPITestContext(t, username, reponame, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + ctxWithDeleteRepo := NewAPITestContext(t, username, reponame, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + otherCtx := ctx + otherCtx.Reponame = "ssh-key-test-repo-2" + otherCtxWithDeleteRepo := ctxWithDeleteRepo + otherCtxWithDeleteRepo.Reponame = otherCtx.Reponame + + failCtx := ctx + failCtx.ExpectedCode = http.StatusUnprocessableEntity + + t.Run("CreateRepository", doAPICreateRepository(ctx, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat + t.Run("CreateOtherRepository", doAPICreateRepository(otherCtx, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat + + withKeyFile(t, keyname, func(keyFile string) { + var userKeyPublicKeyID int64 + t.Run("KeyCanOnlyBeUser", func(t *testing.T) { + dstPath := t.TempDir() + + sshURL := createSSHUrl(ctx.GitPath(), u) + + t.Run("FailToClone", doGitCloneFail(sshURL)) + + t.Run("CreateUserKey", doAPICreateUserKey(ctx, keyname, keyFile, func(t *testing.T, publicKey api.PublicKey) { + userKeyPublicKeyID = publicKey.ID + })) + + t.Run("FailToAddReadOnlyDeployKey", doAPICreateDeployKey(failCtx, keyname, keyFile, true)) + + t.Run("FailToAddDeployKey", doAPICreateDeployKey(failCtx, keyname, keyFile, false)) + + t.Run("Clone", doGitClone(dstPath, sshURL)) + + t.Run("AddChanges", doAddChangesToCheckout(dstPath, "CHANGES1.md")) + + t.Run("Push", doGitPushTestRepository(dstPath, "origin", "master")) + + t.Run("DeleteUserKey", doAPIDeleteUserKey(ctx, userKeyPublicKeyID)) + }) + + t.Run("KeyCanBeAnyDeployButNotUserAswell", func(t *testing.T) { + dstPath := t.TempDir() + + sshURL := createSSHUrl(ctx.GitPath(), u) + + t.Run("FailToClone", doGitCloneFail(sshURL)) + + // Should now be able to add... + t.Run("AddReadOnlyDeployKey", doAPICreateDeployKey(ctx, keyname, keyFile, true)) + + t.Run("Clone", doGitClone(dstPath, sshURL)) + + t.Run("AddChanges", doAddChangesToCheckout(dstPath, "CHANGES2.md")) + + t.Run("FailToPush", doGitPushTestRepositoryFail(dstPath, "origin", "master")) + + otherSSHURL := createSSHUrl(otherCtx.GitPath(), u) + dstOtherPath := t.TempDir() + + t.Run("AddWriterDeployKeyToOther", doAPICreateDeployKey(otherCtx, keyname, keyFile, false)) + + t.Run("CloneOther", doGitClone(dstOtherPath, otherSSHURL)) + + t.Run("AddChangesToOther", doAddChangesToCheckout(dstOtherPath, "CHANGES3.md")) + + t.Run("PushToOther", doGitPushTestRepository(dstOtherPath, "origin", "master")) + + t.Run("FailToCreateUserKey", doAPICreateUserKey(failCtx, keyname, keyFile)) + }) + + t.Run("DeleteRepositoryShouldReleaseKey", func(t *testing.T) { + otherSSHURL := createSSHUrl(otherCtx.GitPath(), u) + dstOtherPath := t.TempDir() + + t.Run("DeleteRepository", doAPIDeleteRepository(ctxWithDeleteRepo)) + + t.Run("FailToCreateUserKeyAsStillDeploy", doAPICreateUserKey(failCtx, keyname, keyFile)) + + t.Run("MakeSureCloneOtherStillWorks", doGitClone(dstOtherPath, otherSSHURL)) + + t.Run("AddChangesToOther", doAddChangesToCheckout(dstOtherPath, "CHANGES3.md")) + + t.Run("PushToOther", doGitPushTestRepository(dstOtherPath, "origin", "master")) + + t.Run("DeleteOtherRepository", doAPIDeleteRepository(otherCtxWithDeleteRepo)) + + t.Run("RecreateRepository", doAPICreateRepository(ctxWithDeleteRepo, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat + + t.Run("CreateUserKey", doAPICreateUserKey(ctx, keyname, keyFile, func(t *testing.T, publicKey api.PublicKey) { + userKeyPublicKeyID = publicKey.ID + })) + + dstPath := t.TempDir() + + sshURL := createSSHUrl(ctx.GitPath(), u) + + t.Run("Clone", doGitClone(dstPath, sshURL)) + + t.Run("AddChanges", doAddChangesToCheckout(dstPath, "CHANGES1.md")) + + t.Run("Push", doGitPushTestRepository(dstPath, "origin", "master")) + }) + + t.Run("DeleteUserKeyShouldRemoveAbilityToClone", func(t *testing.T) { + sshURL := createSSHUrl(ctx.GitPath(), u) + + t.Run("DeleteUserKey", doAPIDeleteUserKey(ctx, userKeyPublicKeyID)) + + t.Run("FailToClone", doGitCloneFail(sshURL)) + }) + }) +} diff --git a/tests/integration/swagger_test.go b/tests/integration/swagger_test.go new file mode 100644 index 0000000..584601f --- /dev/null +++ b/tests/integration/swagger_test.go @@ -0,0 +1,100 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "strings" + "testing" + + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/tests" + + swagger_spec "github.com/go-openapi/spec" + "github.com/stretchr/testify/require" +) + +func getSwagger(t *testing.T) *swagger_spec.Swagger { + t.Helper() + + resp := MakeRequest(t, NewRequest(t, "GET", "/swagger.v1.json"), http.StatusOK) + + swagger := new(swagger_spec.Swagger) + + decoder := json.NewDecoder(resp.Body) + require.NoError(t, decoder.Decode(swagger)) + + return swagger +} + +func checkSwaggerMethodResponse(t *testing.T, path string, method *swagger_spec.Operation, name string, statusCode int, responseType string) { + t.Helper() + + if method == nil { + return + } + + val, ok := method.Responses.StatusCodeResponses[statusCode] + if !ok { + t.Errorf("%s %s is missing response status code %d in swagger", name, path, statusCode) + return + } + + if responseType != val.Ref.String() { + t.Errorf("%s %s has %s response type for %d in swagger (expected %s)", name, path, val.Ref.String(), statusCode, responseType) + } +} + +func checkSwaggerPathResponse(t *testing.T, paths map[string]swagger_spec.PathItem, pathMatch string, statusCode int, responseType string) { + t.Helper() + + for pathName, pathData := range paths { + if pathName != pathMatch { + continue + } + + checkSwaggerMethodResponse(t, pathName, pathData.Get, "GET", statusCode, responseType) + checkSwaggerMethodResponse(t, pathName, pathData.Put, "PUT", statusCode, responseType) + checkSwaggerMethodResponse(t, pathName, pathData.Post, "POST", statusCode, responseType) + checkSwaggerMethodResponse(t, pathName, pathData.Patch, "PATCH", statusCode, responseType) + checkSwaggerMethodResponse(t, pathName, pathData.Delete, "DELETE", statusCode, responseType) + checkSwaggerMethodResponse(t, pathName, pathData.Options, "OPTIONS", statusCode, responseType) + + return + } +} + +func checkSwaggerRouteResponse(t *testing.T, paths map[string]swagger_spec.PathItem, prefix string, statusCode int, responseType string) { + t.Helper() + + for pathName, pathData := range paths { + if !strings.HasPrefix(pathName, prefix) { + continue + } + + checkSwaggerMethodResponse(t, pathName, pathData.Get, "GET", statusCode, responseType) + checkSwaggerMethodResponse(t, pathName, pathData.Put, "PUT", statusCode, responseType) + checkSwaggerMethodResponse(t, pathName, pathData.Post, "POST", statusCode, responseType) + checkSwaggerMethodResponse(t, pathName, pathData.Patch, "PATCH", statusCode, responseType) + checkSwaggerMethodResponse(t, pathName, pathData.Delete, "DELETE", statusCode, responseType) + checkSwaggerMethodResponse(t, pathName, pathData.Options, "OPTIONS", statusCode, responseType) + } +} + +func TestSwaggerUserRoute(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + swagger := getSwagger(t) + + checkSwaggerPathResponse(t, swagger.Paths.Paths, "/user", http.StatusUnauthorized, "#/responses/unauthorized") + checkSwaggerRouteResponse(t, swagger.Paths.Paths, "/user/", http.StatusUnauthorized, "#/responses/unauthorized") +} + +func TestSwaggerUsersRoute(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + swagger := getSwagger(t) + + checkSwaggerRouteResponse(t, swagger.Paths.Paths, "/users/{username}", http.StatusNotFound, "#/responses/notFound") +} diff --git a/tests/integration/timetracking_test.go b/tests/integration/timetracking_test.go new file mode 100644 index 0000000..10e539c --- /dev/null +++ b/tests/integration/timetracking_test.go @@ -0,0 +1,81 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "path" + "testing" + "time" + + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestViewTimetrackingControls(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + testViewTimetrackingControls(t, session, "user2", "repo1", "1", true) + // user2/repo1 +} + +func TestNotViewTimetrackingControls(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user5") + testViewTimetrackingControls(t, session, "user2", "repo1", "1", false) + // user2/repo1 +} + +func TestViewTimetrackingControlsDisabled(t *testing.T) { + defer tests.PrepareTestEnv(t)() + session := loginUser(t, "user2") + testViewTimetrackingControls(t, session, "org3", "repo3", "1", false) +} + +func testViewTimetrackingControls(t *testing.T, session *TestSession, user, repo, issue string, canTrackTime bool) { + req := NewRequest(t, "GET", path.Join(user, repo, "issues", issue)) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, ".timetrack .issue-start-time", canTrackTime) + htmlDoc.AssertElement(t, ".timetrack .issue-add-time", canTrackTime) + + req = NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", issue, "times", "stopwatch", "toggle"), map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + }) + if canTrackTime { + resp = session.MakeRequest(t, req, http.StatusSeeOther) + + req = NewRequest(t, "GET", test.RedirectURL(resp)) + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + + events := htmlDoc.doc.Find(".event > span.text") + assert.Contains(t, events.Last().Text(), "started working") + + htmlDoc.AssertElement(t, ".timetrack .issue-stop-time", true) + htmlDoc.AssertElement(t, ".timetrack .issue-cancel-time", true) + + // Sleep for 1 second to not get wrong order for stopping timer + time.Sleep(time.Second) + + req = NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", issue, "times", "stopwatch", "toggle"), map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + }) + resp = session.MakeRequest(t, req, http.StatusSeeOther) + + req = NewRequest(t, "GET", test.RedirectURL(resp)) + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + + events = htmlDoc.doc.Find(".event > span.text") + assert.Contains(t, events.Last().Text(), "stopped working") + htmlDoc.AssertElement(t, ".event .detail .octicon-clock", true) + } else { + session.MakeRequest(t, req, http.StatusNotFound) + } +} diff --git a/tests/integration/user_avatar_test.go b/tests/integration/user_avatar_test.go new file mode 100644 index 0000000..a5805d0 --- /dev/null +++ b/tests/integration/user_avatar_test.go @@ -0,0 +1,94 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "fmt" + "image/png" + "io" + "mime/multipart" + "net/http" + "net/url" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/avatar" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserAvatar(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo3, is an org + + seed := user2.Email + if len(seed) == 0 { + seed = user2.Name + } + + img, err := avatar.RandomImage([]byte(seed)) + if err != nil { + require.NoError(t, err) + return + } + + session := loginUser(t, "user2") + csrf := GetCSRF(t, session, "/user/settings") + + imgData := &bytes.Buffer{} + + body := &bytes.Buffer{} + + // Setup multi-part + writer := multipart.NewWriter(body) + writer.WriteField("source", "local") + part, err := writer.CreateFormFile("avatar", "avatar-for-testuseravatar.png") + if err != nil { + require.NoError(t, err) + return + } + + if err := png.Encode(imgData, img); err != nil { + require.NoError(t, err) + return + } + + if _, err := io.Copy(part, imgData); err != nil { + require.NoError(t, err) + return + } + + if err := writer.Close(); err != nil { + require.NoError(t, err) + return + } + + req := NewRequestWithBody(t, "POST", "/user/settings/avatar", body) + req.Header.Add("X-Csrf-Token", csrf) + req.Header.Add("Content-Type", writer.FormDataContentType()) + + session.MakeRequest(t, req, http.StatusSeeOther) + + user2 = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo3, is an org + + req = NewRequest(t, "GET", user2.AvatarLinkWithSize(db.DefaultContext, 0)) + _ = session.MakeRequest(t, req, http.StatusOK) + + testGetAvatarRedirect(t, user2) + + // Can't test if the response matches because the image is re-generated on upload but checking that this at least doesn't give a 404 should be enough. + }) +} + +func testGetAvatarRedirect(t *testing.T, user *user_model.User) { + t.Run(fmt.Sprintf("getAvatarRedirect_%s", user.Name), func(t *testing.T) { + req := NewRequestf(t, "GET", "/%s.png", user.Name) + resp := MakeRequest(t, req, http.StatusSeeOther) + assert.EqualValues(t, fmt.Sprintf("/avatars/%s", user.Avatar), resp.Header().Get("location")) + }) +} diff --git a/tests/integration/user_count_test.go b/tests/integration/user_count_test.go new file mode 100644 index 0000000..e76c30c --- /dev/null +++ b/tests/integration/user_count_test.go @@ -0,0 +1,175 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "strconv" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + packages_model "code.gitea.io/gitea/models/packages" + project_model "code.gitea.io/gitea/models/project" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type userCountTest struct { + doer *user_model.User + user *user_model.User + session *TestSession + repoCount int64 + projectCount int64 + packageCount int64 + memberCount int64 + teamCount int64 +} + +func (countTest *userCountTest) Init(t *testing.T, doerID, userID int64) { + countTest.doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: doerID}) + countTest.user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID}) + countTest.session = loginUser(t, countTest.doer.Name) + + var err error + + countTest.repoCount, err = repo_model.CountRepository(db.DefaultContext, &repo_model.SearchRepoOptions{ + Actor: countTest.doer, + OwnerID: countTest.user.ID, + Private: true, + Collaborate: optional.Some(false), + }) + require.NoError(t, err) + + var projectType project_model.Type + if countTest.user.IsOrganization() { + projectType = project_model.TypeOrganization + } else { + projectType = project_model.TypeIndividual + } + countTest.projectCount, err = db.Count[project_model.Project](db.DefaultContext, &project_model.SearchOptions{ + OwnerID: countTest.user.ID, + IsClosed: optional.Some(false), + Type: projectType, + }) + require.NoError(t, err) + countTest.packageCount, err = packages_model.CountOwnerPackages(db.DefaultContext, countTest.user.ID) + require.NoError(t, err) + + if !countTest.user.IsOrganization() { + return + } + + org := (*organization.Organization)(countTest.user) + + isMember, err := org.IsOrgMember(db.DefaultContext, countTest.doer.ID) + require.NoError(t, err) + + countTest.memberCount, err = organization.CountOrgMembers(db.DefaultContext, &organization.FindOrgMembersOpts{ + OrgID: org.ID, + PublicOnly: !isMember, + }) + require.NoError(t, err) + + teams, err := org.LoadTeams(db.DefaultContext) + require.NoError(t, err) + + countTest.teamCount = int64(len(teams)) +} + +func (countTest *userCountTest) getCount(doc *goquery.Document, name string) (int64, error) { + selection := doc.Find(fmt.Sprintf("[test-name=\"%s\"]", name)) + + if selection.Length() != 1 { + return 0, fmt.Errorf("%s was not found", name) + } + + return strconv.ParseInt(selection.Text(), 10, 64) +} + +func (countTest *userCountTest) TestPage(t *testing.T, page string, orgLink bool) { + t.Run(page, func(t *testing.T) { + var userLink string + + if orgLink { + userLink = countTest.user.OrganisationLink() + } else { + userLink = countTest.user.HomeLink() + } + + req := NewRequestf(t, "GET", "%s/%s", userLink, page) + resp := countTest.session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + repoCount, err := countTest.getCount(htmlDoc.doc, "repository-count") + require.NoError(t, err) + assert.Equal(t, countTest.repoCount, repoCount) + + projectCount, err := countTest.getCount(htmlDoc.doc, "project-count") + require.NoError(t, err) + assert.Equal(t, countTest.projectCount, projectCount) + + packageCount, err := countTest.getCount(htmlDoc.doc, "package-count") + require.NoError(t, err) + assert.Equal(t, countTest.packageCount, packageCount) + + if !countTest.user.IsOrganization() { + return + } + + memberCount, err := countTest.getCount(htmlDoc.doc, "member-count") + require.NoError(t, err) + assert.Equal(t, countTest.memberCount, memberCount) + + teamCount, err := countTest.getCount(htmlDoc.doc, "team-count") + require.NoError(t, err) + assert.Equal(t, countTest.teamCount, teamCount) + }) +} + +func TestFrontendHeaderCountUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + countTest := new(userCountTest) + countTest.Init(t, 2, 2) + + countTest.TestPage(t, "", false) + countTest.TestPage(t, "?tab=repositories", false) + countTest.TestPage(t, "-/projects", false) + countTest.TestPage(t, "-/packages", false) + countTest.TestPage(t, "?tab=activity", false) + countTest.TestPage(t, "?tab=stars", false) +} + +func TestFrontendHeaderCountOrg(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + countTest := new(userCountTest) + countTest.Init(t, 15, 17) + + countTest.TestPage(t, "", false) + countTest.TestPage(t, "-/projects", false) + countTest.TestPage(t, "-/packages", false) + countTest.TestPage(t, "members", true) + countTest.TestPage(t, "teams", true) + + countTest.TestPage(t, "settings", true) + countTest.TestPage(t, "settings/hooks", true) + countTest.TestPage(t, "settings/labels", true) + countTest.TestPage(t, "settings/applications", true) + countTest.TestPage(t, "settings/packages", true) + countTest.TestPage(t, "settings/actions/runners", true) + countTest.TestPage(t, "settings/actions/secrets", true) + countTest.TestPage(t, "settings/actions/variables", true) + countTest.TestPage(t, "settings/blocked_users", true) + countTest.TestPage(t, "settings/delete", true) +} diff --git a/tests/integration/user_dashboard_test.go b/tests/integration/user_dashboard_test.go new file mode 100644 index 0000000..abc3e06 --- /dev/null +++ b/tests/integration/user_dashboard_test.go @@ -0,0 +1,30 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "strings" + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/translation" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserDashboardActionLinks(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + session := loginUser(t, "user1") + locale := translation.NewLocale("en-US") + + response := session.MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK) + page := NewHTMLParser(t, response.Body) + links := page.Find("#navbar .dropdown[data-tooltip-content='Create…'] .menu") + assert.EqualValues(t, locale.TrString("new_repo.link"), strings.TrimSpace(links.Find("a[href='/repo/create']").Text())) + assert.EqualValues(t, locale.TrString("new_migrate.link"), strings.TrimSpace(links.Find("a[href='/repo/migrate']").Text())) + assert.EqualValues(t, locale.TrString("new_org.link"), strings.TrimSpace(links.Find("a[href='/org/create']").Text())) +} diff --git a/tests/integration/user_profile_activity_test.go b/tests/integration/user_profile_activity_test.go new file mode 100644 index 0000000..0592523 --- /dev/null +++ b/tests/integration/user_profile_activity_test.go @@ -0,0 +1,112 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestUserProfileActivity ensures visibility and correctness of elements related to activity of a user: +// - RSS feed button (doesn't test `other.ENABLE_FEED:false`) +// - Public activity tab +// - Banner/hint in the tab +// - "Configure" link in the hint +func TestUserProfileActivity(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + // This test needs multiple users with different access statuses to check for all possible states + userAdmin := loginUser(t, "user1") + userRegular := loginUser(t, "user2") + // Activity availability should be the same for guest and another non-admin user, so this is not tested separately + userGuest := emptyTestSession(t) + + // = Public = + + // Set activity visibility of user2 to public. This is the default, but won't hurt to set it before testing. + testChangeUserActivityVisibility(t, userRegular, "off") + + // Verify availability of RSS button and activity tab + testUser2ActivityButtonsAvailability(t, userAdmin, true) + testUser2ActivityButtonsAvailability(t, userRegular, true) + testUser2ActivityButtonsAvailability(t, userGuest, true) + + // Verify the hint for all types of users: admin, self, guest + testUser2ActivityVisibility(t, userAdmin, "This activity is visible to everyone, but as an administrator you can also see interactions in private spaces.", true) + hintLink := testUser2ActivityVisibility(t, userRegular, "Your activity is visible to everyone, except for interactions in private spaces. Configure.", true) + testUser2ActivityVisibility(t, userGuest, "", true) + + // When viewing own profile, the user is offered to configure activity visibility. Verify that the link is correct and works, also check that it links back to the activity tab. + linkCorrect := assert.EqualValues(t, "/user/settings#keep-activity-private", hintLink) + if linkCorrect { + page := NewHTMLParser(t, userRegular.MakeRequest(t, NewRequest(t, "GET", hintLink), http.StatusOK).Body) + activityLink, exists := page.Find(".field:has(.checkbox#keep-activity-private) .help a").Attr("href") + assert.True(t, exists) + assert.EqualValues(t, "/user2?tab=activity", activityLink) + } + + // = Private = + + // Set activity visibility of user2 to private + testChangeUserActivityVisibility(t, userRegular, "on") + + // Verify availability of RSS button and activity tab + testUser2ActivityButtonsAvailability(t, userAdmin, true) + testUser2ActivityButtonsAvailability(t, userRegular, true) + testUser2ActivityButtonsAvailability(t, userGuest, false) + + // Verify the hint for all types of users: admin, self, guest + testUser2ActivityVisibility(t, userAdmin, "This activity is visible to you because you're an administrator, but the user wants it to remain private.", true) + hintLink = testUser2ActivityVisibility(t, userRegular, "Your activity is only visible to you and the instance administrators. Configure.", true) + testUser2ActivityVisibility(t, userGuest, "This user has disabled the public visibility of the activity.", false) + + // Verify that Configure link is correct + assert.EqualValues(t, "/user/settings#keep-activity-private", hintLink) + }) +} + +// testChangeUserActivityVisibility allows to easily change visibility of public activity for a user +func testChangeUserActivityVisibility(t *testing.T, session *TestSession, newState string) { + t.Helper() + session.MakeRequest(t, NewRequestWithValues(t, "POST", "/user/settings", + map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings"), + "keep_activity_private": newState, + }), http.StatusSeeOther) +} + +// testUser2ActivityVisibility checks visibility of UI elements on /<user>?tab=activity +// It also returns the account visibility link if it is present on the page. +func testUser2ActivityVisibility(t *testing.T, session *TestSession, hint string, availability bool) string { + t.Helper() + response := session.MakeRequest(t, NewRequest(t, "GET", "/user2?tab=activity"), http.StatusOK) + page := NewHTMLParser(t, response.Body) + // Check hint visibility and correctness + testSelectorEquals(t, page, "#visibility-hint", hint) + hintLink, hintLinkExists := page.Find("#visibility-hint a").Attr("href") + + // Check that the hint aligns with the actual feed availability + assert.EqualValues(t, availability, page.Find("#activity-feed").Length() > 0) + + // Check availability of RSS feed button too + assert.EqualValues(t, availability, page.Find("#profile-avatar-card a[href='/user2.rss']").Length() > 0) + + // Check that the current tab is displayed and is active regardless of it's actual availability + // For example, on /<user> it wouldn't be available to guest, but it should be still present on /<user>?tab=activity + assert.Positive(t, page.Find("overflow-menu .active.item[href='/user2?tab=activity']").Length()) + if hintLinkExists { + return hintLink + } + return "" +} + +// testUser2ActivityButtonsAvailability checks visibility of Public activity tab on main profile page +func testUser2ActivityButtonsAvailability(t *testing.T, session *TestSession, buttons bool) { + t.Helper() + response := session.MakeRequest(t, NewRequest(t, "GET", "/user2"), http.StatusOK) + page := NewHTMLParser(t, response.Body) + assert.EqualValues(t, buttons, page.Find("overflow-menu .item[href='/user2?tab=activity']").Length() > 0) +} diff --git a/tests/integration/user_profile_follows_test.go b/tests/integration/user_profile_follows_test.go new file mode 100644 index 0000000..bad0944 --- /dev/null +++ b/tests/integration/user_profile_follows_test.go @@ -0,0 +1,132 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestUserProfileFollows is a test for user following counters, pages and titles. +// It tests that: +// - Followers and Following tabs always have titles present and always use correct plurals +// - Followers and Following lists have correct amounts of items +// - %d followers and %following counters are always present and always have correct numbers and use correct plurals +func TestUserProfileFollows(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + // This test needs 3 users to check for all possible states + // The accounts of user3 and user4 are not functioning + user1 := loginUser(t, "user1") + user2 := loginUser(t, "user2") + user5 := loginUser(t, "user5") + + followersLink := "#profile-avatar-card a[href='/user1?tab=followers']" + followingLink := "#profile-avatar-card a[href='/user1?tab=following']" + listHeader := ".user-cards h2" + listItems := ".user-cards .list" + + // = No follows = + + var followCount int + + // Request the profile of user1, the Followers tab + response := user1.MakeRequest(t, NewRequest(t, "GET", "/user1?tab=followers"), http.StatusOK) + page := NewHTMLParser(t, response.Body) + + // Verify that user1 has no followers + testSelectorEquals(t, page, followersLink, "0 followers") + testSelectorEquals(t, page, listHeader, "Followers") + testListCount(t, page, listItems, followCount) + + // Request the profile of user1, the Following tab + response = user1.MakeRequest(t, NewRequest(t, "GET", "/user1?tab=following"), http.StatusOK) + page = NewHTMLParser(t, response.Body) + + // Verify that user1 does not follow anyone + testSelectorEquals(t, page, followingLink, "0 following") + testSelectorEquals(t, page, listHeader, "Following") + testListCount(t, page, listItems, followCount) + + // Make user1 and user2 follow each other + testUserFollowUser(t, user1, "user2") + testUserFollowUser(t, user2, "user1") + + // = 1 follow each = + + followCount++ + + // Request the profile of user1, the Followers tab + response = user1.MakeRequest(t, NewRequest(t, "GET", "/user1?tab=followers"), http.StatusOK) + page = NewHTMLParser(t, response.Body) + + // Verify it is now followed by 1 user + testSelectorEquals(t, page, followersLink, "1 follower") + testSelectorEquals(t, page, listHeader, "Follower") + testListCount(t, page, listItems, followCount) + + // Request the profile of user1, the Following tab + response = user1.MakeRequest(t, NewRequest(t, "GET", "/user1?tab=following"), http.StatusOK) + page = NewHTMLParser(t, response.Body) + + // Verify it now follows follows 1 user + testSelectorEquals(t, page, followingLink, "1 following") + testSelectorEquals(t, page, listHeader, "Following") + testListCount(t, page, listItems, followCount) + + // Make user1 and user3 follow each other + testUserFollowUser(t, user1, "user5") + testUserFollowUser(t, user5, "user1") + + // = 2 follows = + + followCount++ + + // Request the profile of user1, the Followers tab + response = user1.MakeRequest(t, NewRequest(t, "GET", "/user1?tab=followers"), http.StatusOK) + page = NewHTMLParser(t, response.Body) + + // Verify it is now followed by 2 users + testSelectorEquals(t, page, followersLink, "2 followers") + testSelectorEquals(t, page, listHeader, "Followers") + testListCount(t, page, listItems, followCount) + + // Request the profile of user1, the Following tab + response = user1.MakeRequest(t, NewRequest(t, "GET", "/user1?tab=following"), http.StatusOK) + page = NewHTMLParser(t, response.Body) + + // Verify it now follows follows 2 users + testSelectorEquals(t, page, followingLink, "2 following") + testSelectorEquals(t, page, listHeader, "Following") + testListCount(t, page, listItems, followCount) + }) +} + +// testUserFollowUser simply follows a user `following` by session of user `follower` +func testUserFollowUser(t *testing.T, follower *TestSession, following string) { + t.Helper() + follower.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s?action=follow", following), + map[string]string{ + "_csrf": GetCSRF(t, follower, fmt.Sprintf("/%s", following)), + }), http.StatusOK) +} + +// testSelectorEquals prevents duplication of a lot of code for tests with many checks +func testSelectorEquals(t *testing.T, page *HTMLDoc, selector, expectedContent string) { + t.Helper() + element := page.Find(selector) + content := strings.TrimSpace(element.Text()) + assert.Equal(t, expectedContent, content) +} + +// testListCount checks that the list on the page has the right amount of items +func testListCount(t *testing.T, page *HTMLDoc, selector string, expectedCount int) { + t.Helper() + itemCount := page.Find(selector).Children().Length() + assert.Equal(t, expectedCount, itemCount) +} diff --git a/tests/integration/user_profile_test.go b/tests/integration/user_profile_test.go new file mode 100644 index 0000000..5532403 --- /dev/null +++ b/tests/integration/user_profile_test.go @@ -0,0 +1,67 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "strings" + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestUserProfile(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + checkReadme := func(t *testing.T, title, readmeFilename string, expectedCount int) { + t.Run(title, func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Prepare the test repository + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + var ops []*files_service.ChangeRepoFile + op := "create" + if readmeFilename != "README.md" { + ops = append(ops, &files_service.ChangeRepoFile{ + Operation: "delete", + TreePath: "README.md", + }) + } else { + op = "update" + } + if readmeFilename != "" { + ops = append(ops, &files_service.ChangeRepoFile{ + Operation: op, + TreePath: readmeFilename, + ContentReader: strings.NewReader("# Hi!\n"), + }) + } + + _, _, f := tests.CreateDeclarativeRepo(t, user2, ".profile", nil, nil, ops) + defer f() + + // Perform the test + req := NewRequest(t, "GET", "/user2") + resp := MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + readmeCount := doc.Find("#readme_profile").Length() + + assert.Equal(t, expectedCount, readmeCount) + }) + } + + checkReadme(t, "No readme", "", 0) + checkReadme(t, "README.md", "README.md", 1) + checkReadme(t, "readme.md", "readme.md", 1) + checkReadme(t, "ReadMe.mD", "ReadMe.mD", 1) + checkReadme(t, "readme.org does not render", "README.org", 0) + }) +} diff --git a/tests/integration/user_test.go b/tests/integration/user_test.go new file mode 100644 index 0000000..e3875ee --- /dev/null +++ b/tests/integration/user_test.go @@ -0,0 +1,1017 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "bytes" + "encoding/hex" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/translation" + gitea_context "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/mailer" + "code.gitea.io/gitea/tests" + + "github.com/pquerna/otp/totp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestViewUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/user2") + MakeRequest(t, req, http.StatusOK) +} + +func TestRenameUsername(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings"), + "name": "newUsername", + "email": "user2@example.com", + "language": "en-US", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "newUsername"}) + unittest.AssertNotExistsBean(t, &user_model.User{Name: "user2"}) +} + +func TestRenameInvalidUsername(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + invalidUsernames := []string{ + "%2f*", + "%2f.", + "%2f..", + "%00", + "thisHas ASpace", + "p<A>tho>lo<gical", + ".", + "..", + ".well-known", + ".abc", + "abc.", + "a..bc", + "a...bc", + "a.-bc", + "a._bc", + "a_-bc", + "a/bc", + "☁️", + "-", + "--diff", + "-im-here", + "a space", + } + + session := loginUser(t, "user2") + for _, invalidUsername := range invalidUsernames { + t.Logf("Testing username %s", invalidUsername) + + req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings"), + "name": invalidUsername, + "email": "user2@example.com", + }) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, + htmlDoc.doc.Find(".ui.negative.message").Text(), + translation.NewLocale("en-US").TrString("form.username_error"), + ) + + unittest.AssertNotExistsBean(t, &user_model.User{Name: invalidUsername}) + } +} + +func TestRenameReservedUsername(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + reservedUsernames := []string{ + // ".", "..", ".well-known", // The names are not only reserved but also invalid + "admin", + "api", + "assets", + "attachments", + "avatar", + "avatars", + "captcha", + "commits", + "debug", + "devtest", + "error", + "explore", + "favicon.ico", + "ghost", + "issues", + "login", + "manifest.json", + "metrics", + "milestones", + "new", + "notifications", + "org", + "pulls", + "raw", + "repo", + "repo-avatars", + "robots.txt", + "search", + "serviceworker.js", + "ssh_info", + "swagger.v1.json", + "user", + "v2", + } + + session := loginUser(t, "user2") + for _, reservedUsername := range reservedUsernames { + t.Logf("Testing username %s", reservedUsername) + req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings"), + "name": reservedUsername, + "email": "user2@example.com", + "language": "en-US", + }) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + + req = NewRequest(t, "GET", test.RedirectURL(resp)) + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, + htmlDoc.doc.Find(".ui.negative.message").Text(), + translation.NewLocale("en-US").TrString("user.form.name_reserved", reservedUsername), + ) + + unittest.AssertNotExistsBean(t, &user_model.User{Name: reservedUsername}) + } +} + +func TestExportUserGPGKeys(t *testing.T) { + defer tests.PrepareTestEnv(t)() + // Export empty key list + testExportUserGPGKeys(t, "user1", `-----BEGIN PGP PUBLIC KEY BLOCK----- +Note: This user hasn't uploaded any GPG keys. + + +=twTO +-----END PGP PUBLIC KEY BLOCK-----`) + // Import key + // User1 <user1@example.com> + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + testCreateGPGKey(t, session.MakeRequest, token, http.StatusCreated, `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBFyy/VUBCADJ7zbM20Z1RWmFoVgp5WkQfI2rU1Vj9cQHes9i42wVLLtcbPeo +QzubgzvMPITDy7nfWxgSf83E23DoHQ1ACFbQh/6eFSRrjsusp3YQ/08NSfPPbcu8 +0M5G+VGwSfzS5uEcwBVQmHyKdcOZIERTNMtYZx1C3bjLD1XVJHvWz9D72Uq4qeO3 +8SR+lzp5n6ppUakcmRnxt3nGRBj1+hEGkdgzyPo93iy+WioegY2lwCA9xMEo5dah +BmYxWx51zyiXYlReTaxlyb3/nuSUt8IcW3Q8zjdtJj4Nu8U1SpV8EdaA1I9IPbHW +510OSLmD3XhqHH5m6mIxL1YoWxk3V7gpDROtABEBAAG0GVVzZXIxIDx1c2VyMUBl +eGFtcGxlLmNvbT6JAU4EEwEIADgWIQTQEbrYxmXsp1z3j7z9+v0I6RSEHwUCXLL9 +VQIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRD9+v0I6RSEH22YCACFqL5+ +6M0m18AMC/pumcpnnmvAS1GrrKTF8nOROA1augZwp1WCNuKw2R6uOJIHANrYECSn +u7+j6GBP2gbIW8mSAzS6HWCs7GGiPpVtT4wcu8wljUI6BxjpyZtoEkriyBjt6HfK +rkegbkuySoJvjq4IcO5D1LB1JWgsUjMYQJj/ZpBIzVtjG9QtFSOiT1Hct4PoZHdC +nsdSgyCkwRZXG+u3kT/wP9F663ba4o16vYlz3dCGo66lF2tyoG3qcyZ1OUzUrnuv +96ytAzT6XIhrE0nVoBprMxFF5zExotJD3bHjcGBFNLf944bhjKee3U6t9+OsfJVC +l7N5xxIawCuTQdbfuQENBFyy/VUBCADe61yGEoTwKfsOKIhxLaNoRmD883O0tiWt +soO/HPj9dPQLTOiwXgSgSCd8C+LNxGKct87wgFozpah4tDLC6c0nALuHJ0SLbkfz +55aRhLeOOcrAydatDp72GroXzqpZ0xZBk5wjIWdgEol2GmVRM8QGbeuakU/HVz5y +lPzxUUocgdbSi3GE3zbzijQzVJdyL/kw/KP7pKT/PPKKJ2C5NQDLy0XGKEHddXGR +EWKkVlRalxq/TjfaMR0bi3MpezBsQmp99ATPO/d7trayZUxQHRtXzGFiOXfDHATr +qN730sODjqvU+mpc/SHCRwh9qWDjZRHSuKU5YDBjb5jIQJivZsQ/ABEBAAGJATYE +GAEIACAWIQTQEbrYxmXsp1z3j7z9+v0I6RSEHwUCXLL9VQIbDAAKCRD9+v0I6RSE +H7WoB/4tXl+97rQ6owPCGSVp1Xbwt2521V7COgsOFRVTRTryEWxRW8mm0S7wQvax +C0TLXKur6NVYQMn01iyL+FZzRpEWNuYF3f9QeeLJ/+l2DafESNhNTy17+RPmacK6 +21dccpqchByVw/UMDeHSyjQLiG2lxzt8Gfx2gHmSbrq3aWovTGyz6JTffZvfy/n2 +0Hm437OBPazO0gZyXhdV2PE5RSUfvAgm44235tcV5EV0d32TJDfv61+Vr2GUbah6 +7XhJ1v6JYuh8kaYaEz8OpZDeh7f6Ho6PzJrsy/TKTKhGgZNINj1iaPFyOkQgKR5M +GrE0MHOxUbc9tbtyk0F1SuzREUBH +=DDXw +-----END PGP PUBLIC KEY BLOCK-----`) + // Export new key + testExportUserGPGKeys(t, "user1", `-----BEGIN PGP PUBLIC KEY BLOCK----- + +xsBNBFyy/VUBCADJ7zbM20Z1RWmFoVgp5WkQfI2rU1Vj9cQHes9i42wVLLtcbPeo +QzubgzvMPITDy7nfWxgSf83E23DoHQ1ACFbQh/6eFSRrjsusp3YQ/08NSfPPbcu8 +0M5G+VGwSfzS5uEcwBVQmHyKdcOZIERTNMtYZx1C3bjLD1XVJHvWz9D72Uq4qeO3 +8SR+lzp5n6ppUakcmRnxt3nGRBj1+hEGkdgzyPo93iy+WioegY2lwCA9xMEo5dah +BmYxWx51zyiXYlReTaxlyb3/nuSUt8IcW3Q8zjdtJj4Nu8U1SpV8EdaA1I9IPbHW +510OSLmD3XhqHH5m6mIxL1YoWxk3V7gpDROtABEBAAHNGVVzZXIxIDx1c2VyMUBl +eGFtcGxlLmNvbT7CwI4EEwEIADgWIQTQEbrYxmXsp1z3j7z9+v0I6RSEHwUCXLL9 +VQIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRD9+v0I6RSEH22YCACFqL5+ +6M0m18AMC/pumcpnnmvAS1GrrKTF8nOROA1augZwp1WCNuKw2R6uOJIHANrYECSn +u7+j6GBP2gbIW8mSAzS6HWCs7GGiPpVtT4wcu8wljUI6BxjpyZtoEkriyBjt6HfK +rkegbkuySoJvjq4IcO5D1LB1JWgsUjMYQJj/ZpBIzVtjG9QtFSOiT1Hct4PoZHdC +nsdSgyCkwRZXG+u3kT/wP9F663ba4o16vYlz3dCGo66lF2tyoG3qcyZ1OUzUrnuv +96ytAzT6XIhrE0nVoBprMxFF5zExotJD3bHjcGBFNLf944bhjKee3U6t9+OsfJVC +l7N5xxIawCuTQdbfzsBNBFyy/VUBCADe61yGEoTwKfsOKIhxLaNoRmD883O0tiWt +soO/HPj9dPQLTOiwXgSgSCd8C+LNxGKct87wgFozpah4tDLC6c0nALuHJ0SLbkfz +55aRhLeOOcrAydatDp72GroXzqpZ0xZBk5wjIWdgEol2GmVRM8QGbeuakU/HVz5y +lPzxUUocgdbSi3GE3zbzijQzVJdyL/kw/KP7pKT/PPKKJ2C5NQDLy0XGKEHddXGR +EWKkVlRalxq/TjfaMR0bi3MpezBsQmp99ATPO/d7trayZUxQHRtXzGFiOXfDHATr +qN730sODjqvU+mpc/SHCRwh9qWDjZRHSuKU5YDBjb5jIQJivZsQ/ABEBAAHCwHYE +GAEIACAWIQTQEbrYxmXsp1z3j7z9+v0I6RSEHwUCXLL9VQIbDAAKCRD9+v0I6RSE +H7WoB/4tXl+97rQ6owPCGSVp1Xbwt2521V7COgsOFRVTRTryEWxRW8mm0S7wQvax +C0TLXKur6NVYQMn01iyL+FZzRpEWNuYF3f9QeeLJ/+l2DafESNhNTy17+RPmacK6 +21dccpqchByVw/UMDeHSyjQLiG2lxzt8Gfx2gHmSbrq3aWovTGyz6JTffZvfy/n2 +0Hm437OBPazO0gZyXhdV2PE5RSUfvAgm44235tcV5EV0d32TJDfv61+Vr2GUbah6 +7XhJ1v6JYuh8kaYaEz8OpZDeh7f6Ho6PzJrsy/TKTKhGgZNINj1iaPFyOkQgKR5M +GrE0MHOxUbc9tbtyk0F1SuzREUBH +=WFf5 +-----END PGP PUBLIC KEY BLOCK-----`) +} + +func testExportUserGPGKeys(t *testing.T, user, expected string) { + session := loginUser(t, user) + t.Logf("Testing username %s export gpg keys", user) + req := NewRequest(t, "GET", "/"+user+".gpg") + resp := session.MakeRequest(t, req, http.StatusOK) + // t.Log(resp.Body.String()) + assert.Equal(t, expected, resp.Body.String()) +} + +func TestGetUserRss(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("Normal", func(t *testing.T) { + user34 := "the_34-user.with.all.allowedChars" + req := NewRequestf(t, "GET", "/%s.rss", user34) + resp := MakeRequest(t, req, http.StatusOK) + if assert.EqualValues(t, "application/rss+xml;charset=utf-8", resp.Header().Get("Content-Type")) { + rssDoc := NewHTMLParser(t, resp.Body).Find("channel") + title, _ := rssDoc.ChildrenFiltered("title").Html() + assert.EqualValues(t, "Feed of "the_1-user.with.all.allowedChars"", title) + description, _ := rssDoc.ChildrenFiltered("description").Html() + assert.EqualValues(t, "<p dir="auto">some <a href="https://commonmark.org/" rel="nofollow">commonmark</a>!</p>\n", description) + } + }) + t.Run("Non-existent user", func(t *testing.T) { + session := loginUser(t, "user2") + req := NewRequestf(t, "GET", "/non-existent-user.rss") + session.MakeRequest(t, req, http.StatusNotFound) + }) +} + +func TestListStopWatches(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + req := NewRequest(t, "GET", "/user/stopwatches") + resp := session.MakeRequest(t, req, http.StatusOK) + var apiWatches []*api.StopWatch + DecodeJSON(t, resp, &apiWatches) + stopwatch := unittest.AssertExistsAndLoadBean(t, &issues_model.Stopwatch{UserID: owner.ID}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: stopwatch.IssueID}) + if assert.Len(t, apiWatches, 1) { + assert.EqualValues(t, stopwatch.CreatedUnix.AsTime().Unix(), apiWatches[0].Created.Unix()) + assert.EqualValues(t, issue.Index, apiWatches[0].IssueIndex) + assert.EqualValues(t, issue.Title, apiWatches[0].IssueTitle) + assert.EqualValues(t, repo.Name, apiWatches[0].RepoName) + assert.EqualValues(t, repo.OwnerName, apiWatches[0].RepoOwnerName) + assert.Positive(t, apiWatches[0].Seconds) + } +} + +func TestUserLocationMapLink(t *testing.T) { + setting.Service.UserLocationMapURL = "https://example/foo/" + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings"), + "name": "user2", + "email": "user@example.com", + "language": "en-US", + "location": "A/b", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + req = NewRequest(t, "GET", "/user2/") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, `a[href="https://example/foo/A%2Fb"]`, true) +} + +func TestUserHints(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + // Create a known-good repo, with only one unit enabled + repo, _, f := tests.CreateDeclarativeRepo(t, user, "", []unit_model.Type{ + unit_model.TypeCode, + }, []unit_model.Type{ + unit_model.TypePullRequests, + unit_model.TypeProjects, + unit_model.TypePackages, + unit_model.TypeActions, + unit_model.TypeIssues, + unit_model.TypeWiki, + }, nil) + defer f() + + ensureRepoUnitHints := func(t *testing.T, hints bool) { + t.Helper() + + req := NewRequestWithJSON(t, "PATCH", "/api/v1/user/settings", &api.UserSettingsOptions{ + EnableRepoUnitHints: &hints, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var userSettings api.UserSettings + DecodeJSON(t, resp, &userSettings) + assert.Equal(t, hints, userSettings.EnableRepoUnitHints) + } + + t.Run("API", func(t *testing.T) { + t.Run("setting hints on and off", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + ensureRepoUnitHints(t, true) + ensureRepoUnitHints(t, false) + }) + + t.Run("retrieving settings", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + for _, v := range []bool{true, false} { + ensureRepoUnitHints(t, v) + + req := NewRequest(t, "GET", "/api/v1/user/settings").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var userSettings api.UserSettings + DecodeJSON(t, resp, &userSettings) + assert.Equal(t, v, userSettings.EnableRepoUnitHints) + } + }) + }) + + t.Run("user settings", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Set a known-good state, that isn't the default + ensureRepoUnitHints(t, false) + + assertHintState := func(t *testing.T, enabled bool) { + t.Helper() + + req := NewRequest(t, "GET", "/user/settings/appearance") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + _, hintChecked := htmlDoc.Find(`input[name="enable_repo_unit_hints"]`).Attr("checked") + assert.Equal(t, enabled, hintChecked) + + link, _ := htmlDoc.Find("form[action='/user/settings/appearance/language'] a").Attr("href") + assert.EqualValues(t, "https://forgejo.org/docs/next/contributor/localization/", link) + } + + t.Run("view", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + assertHintState(t, false) + }) + + t.Run("change", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", "/user/settings/appearance/hints", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings/appearance"), + "enable_repo_unit_hints": "true", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + assertHintState(t, true) + }) + }) + + t.Run("repo view", func(t *testing.T) { + assertAddMore := func(t *testing.T, present bool) { + t.Helper() + + req := NewRequest(t, "GET", repo.Link()) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, fmt.Sprintf("a[href='%s/settings/units']", repo.Link()), present) + } + + t.Run("hints enabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + ensureRepoUnitHints(t, true) + assertAddMore(t, true) + }) + + t.Run("hints disabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + ensureRepoUnitHints(t, false) + assertAddMore(t, false) + }) + }) +} + +func TestUserPronouns(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}) + adminSession := loginUser(t, adminUser.Name) + adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeWriteAdmin) + + t.Run("API", func(t *testing.T) { + t.Run("user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/user").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + // We check the raw JSON, because we want to test the response, not + // what it decodes into. Contents doesn't matter, we're testing the + // presence only. + assert.Contains(t, resp.Body.String(), `"pronouns":`) + }) + + t.Run("users/{username}", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/api/v1/users/user2") + resp := MakeRequest(t, req, http.StatusOK) + + // We check the raw JSON, because we want to test the response, not + // what it decodes into. Contents doesn't matter, we're testing the + // presence only. + assert.Contains(t, resp.Body.String(), `"pronouns":`) + }) + + t.Run("user/settings", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Set pronouns first + pronouns := "they/them" + req := NewRequestWithJSON(t, "PATCH", "/api/v1/user/settings", &api.UserSettingsOptions{ + Pronouns: &pronouns, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + // Verify the response + var user *api.UserSettings + DecodeJSON(t, resp, &user) + assert.Equal(t, pronouns, user.Pronouns) + + // Verify retrieving the settings again + req = NewRequest(t, "GET", "/api/v1/user/settings").AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + DecodeJSON(t, resp, &user) + assert.Equal(t, pronouns, user.Pronouns) + }) + + t.Run("admin/users/{username}", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Set the pronouns for user2 + pronouns := "she/her" + req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/users/user2", &api.EditUserOption{ + Pronouns: &pronouns, + }).AddTokenAuth(adminToken) + resp := MakeRequest(t, req, http.StatusOK) + + // Verify the API response + var user *api.User + DecodeJSON(t, resp, &user) + assert.Equal(t, pronouns, user.Pronouns) + + // Verify via user2 too + req = NewRequest(t, "GET", "/api/v1/user").AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &user) + assert.Equal(t, pronouns, user.Pronouns) + }) + }) + + t.Run("UI", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Set the pronouns to a known state via the API + pronouns := "she/her" + req := NewRequestWithJSON(t, "PATCH", "/api/v1/user/settings", &api.UserSettingsOptions{ + Pronouns: &pronouns, + }).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + t.Run("profile view", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user2") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + userNameAndPronouns := strings.TrimSpace(htmlDoc.Find(".profile-avatar-name .username").Text()) + assert.Contains(t, userNameAndPronouns, pronouns) + }) + + t.Run("settings", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/user/settings") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + // Check that the field is present + pronounField, has := htmlDoc.Find(`input[name="pronouns"]`).Attr("value") + assert.True(t, has) + assert.Equal(t, pronouns, pronounField) + + // Check that updating the field works + newPronouns := "they/them" + req = NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings"), + "pronouns": newPronouns, + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}) + assert.Equal(t, newPronouns, user2.Pronouns) + }) + + t.Run("admin settings", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}) + + req := NewRequestf(t, "GET", "/admin/users/%d/edit", user2.ID) + resp := adminSession.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + // Check that the pronouns field is present + pronounField, has := htmlDoc.Find(`input[name="pronouns"]`).Attr("value") + assert.True(t, has) + assert.NotEmpty(t, pronounField) + + // Check that updating the field works + newPronouns := "it/its" + editURI := fmt.Sprintf("/admin/users/%d/edit", user2.ID) + req = NewRequestWithValues(t, "POST", editURI, map[string]string{ + "_csrf": GetCSRF(t, adminSession, editURI), + "login_type": "0-0", + "login_name": user2.LoginName, + "email": user2.Email, + "pronouns": newPronouns, + }) + adminSession.MakeRequest(t, req, http.StatusSeeOther) + + user2New := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}) + assert.Equal(t, newPronouns, user2New.Pronouns) + }) + }) + + t.Run("unspecified", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Set the pronouns to Unspecified (an empty string) via the API + pronouns := "" + req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/users/user2", &api.EditUserOption{ + Pronouns: &pronouns, + }).AddTokenAuth(adminToken) + MakeRequest(t, req, http.StatusOK) + + // Verify that the profile page does not display any pronouns, nor the separator + req = NewRequest(t, "GET", "/user2") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + userName := strings.TrimSpace(htmlDoc.Find(".profile-avatar-name .username").Text()) + assert.EqualValues(t, "user2", userName) + }) +} + +func TestUserTOTPMail(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + + t.Run("No security keys", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + called := false + defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) { + assert.Len(t, msgs, 1) + assert.Equal(t, user.EmailTo(), msgs[0].To) + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.totp_disabled.subject"), msgs[0].Subject) + assert.Contains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.totp_disabled.no_2fa")) + called = true + })() + + unittest.AssertSuccessfulInsert(t, &auth_model.TwoFactor{UID: user.ID}) + req := NewRequestWithValues(t, "POST", "/user/settings/security/two_factor/disable", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings/security"), + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + assert.True(t, called) + unittest.AssertExistsIf(t, false, &auth_model.TwoFactor{UID: user.ID}) + }) + + t.Run("with security keys", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + called := false + defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) { + assert.Len(t, msgs, 1) + assert.Equal(t, user.EmailTo(), msgs[0].To) + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.totp_disabled.subject"), msgs[0].Subject) + assert.NotContains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.totp_disabled.no_2fa")) + called = true + })() + + unittest.AssertSuccessfulInsert(t, &auth_model.TwoFactor{UID: user.ID}) + unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID}) + req := NewRequestWithValues(t, "POST", "/user/settings/security/two_factor/disable", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings/security"), + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + assert.True(t, called) + unittest.AssertExistsIf(t, false, &auth_model.TwoFactor{UID: user.ID}) + }) +} + +func TestUserSecurityKeyMail(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + + t.Run("Normal", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + called := false + defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) { + assert.Len(t, msgs, 1) + assert.Equal(t, user.EmailTo(), msgs[0].To) + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.removed_security_key.subject"), msgs[0].Subject) + assert.Contains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.removed_security_key.no_2fa")) + assert.Contains(t, msgs[0].Body, "Little Bobby Tables's primary key") + called = true + })() + + unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's primary key"}) + id := unittest.AssertExistsAndLoadBean(t, &auth_model.WebAuthnCredential{UserID: user.ID}).ID + req := NewRequestWithValues(t, "POST", "/user/settings/security/webauthn/delete", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings/security"), + "id": strconv.FormatInt(id, 10), + }) + session.MakeRequest(t, req, http.StatusOK) + + assert.True(t, called) + unittest.AssertExistsIf(t, false, &auth_model.WebAuthnCredential{UserID: user.ID}) + }) + + t.Run("With TOTP", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + called := false + defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) { + assert.Len(t, msgs, 1) + assert.Equal(t, user.EmailTo(), msgs[0].To) + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.removed_security_key.subject"), msgs[0].Subject) + assert.NotContains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.removed_security_key.no_2fa")) + assert.Contains(t, msgs[0].Body, "Little Bobby Tables's primary key") + called = true + })() + + unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's primary key"}) + id := unittest.AssertExistsAndLoadBean(t, &auth_model.WebAuthnCredential{UserID: user.ID}).ID + unittest.AssertSuccessfulInsert(t, &auth_model.TwoFactor{UID: user.ID}) + req := NewRequestWithValues(t, "POST", "/user/settings/security/webauthn/delete", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings/security"), + "id": strconv.FormatInt(id, 10), + }) + session.MakeRequest(t, req, http.StatusOK) + + assert.True(t, called) + unittest.AssertExistsIf(t, false, &auth_model.WebAuthnCredential{UserID: user.ID}) + }) + + t.Run("Two security keys", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + called := false + defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) { + assert.Len(t, msgs, 1) + assert.Equal(t, user.EmailTo(), msgs[0].To) + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.removed_security_key.subject"), msgs[0].Subject) + assert.NotContains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.removed_security_key.no_2fa")) + assert.Contains(t, msgs[0].Body, "Little Bobby Tables's primary key") + called = true + })() + + unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's primary key"}) + id := unittest.AssertExistsAndLoadBean(t, &auth_model.WebAuthnCredential{UserID: user.ID}).ID + unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's evil key"}) + req := NewRequestWithValues(t, "POST", "/user/settings/security/webauthn/delete", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings/security"), + "id": strconv.FormatInt(id, 10), + }) + session.MakeRequest(t, req, http.StatusOK) + + assert.True(t, called) + unittest.AssertExistsIf(t, false, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's primary key"}) + unittest.AssertExistsIf(t, true, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's evil key"}) + }) +} + +func TestUserTOTPEnrolled(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user.Name) + + enrollTOTP := func(t *testing.T) { + t.Helper() + + req := NewRequest(t, "GET", "/user/settings/security/two_factor/enroll") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + totpSecretKey, has := htmlDoc.Find(".twofa img[src^='data:image/png;base64']").Attr("alt") + assert.True(t, has) + + currentTOTP, err := totp.GenerateCode(totpSecretKey, time.Now()) + require.NoError(t, err) + + req = NewRequestWithValues(t, "POST", "/user/settings/security/two_factor/enroll", map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "passcode": currentTOTP, + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := session.GetCookie(gitea_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.Contains(t, flashCookie.Value, "success%3DYour%2Baccount%2Bhas%2Bbeen%2Bsuccessfully%2Benrolled.") + + unittest.AssertSuccessfulDelete(t, &auth_model.TwoFactor{UID: user.ID}) + } + + t.Run("No WebAuthn enabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + called := false + defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) { + assert.Len(t, msgs, 1) + assert.Equal(t, user.EmailTo(), msgs[0].To) + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.totp_enrolled.subject"), msgs[0].Subject) + assert.Contains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.totp_enrolled.text_1.no_webauthn")) + called = true + })() + + enrollTOTP(t) + + assert.True(t, called) + }) + + t.Run("With WebAuthn enabled", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + called := false + defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) { + assert.Len(t, msgs, 1) + assert.Equal(t, user.EmailTo(), msgs[0].To) + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.totp_enrolled.subject"), msgs[0].Subject) + assert.Contains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.totp_enrolled.text_1.has_webauthn")) + called = true + })() + + unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Cueball's primary key"}) + enrollTOTP(t) + + assert.True(t, called) + }) +} + +func TestUserRepos(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + cases := map[string][]string{ + "alphabetically": {"repo6", "repo7", "repo8"}, + "recentupdate": {"repo7", "repo8", "repo6"}, + "reversealphabetically": {"repo8", "repo7", "repo6"}, + } + + session := loginUser(t, "user10") + for sortBy, repos := range cases { + req := NewRequest(t, "GET", "/user10?sort="+sortBy) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + + sel := htmlDoc.doc.Find("a.name") + assert.Len(t, repos, len(sel.Nodes)) + for i := 0; i < len(repos); i++ { + assert.EqualValues(t, repos[i], strings.TrimSpace(sel.Eq(i).Text())) + } + } +} + +func TestUserActivate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm, true)() + + called := false + code := "" + defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) { + called = true + assert.Len(t, msgs, 1) + assert.Equal(t, `"doesnotexist" <doesnotexist@example.com>`, msgs[0].To) + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.activate_account"), msgs[0].Subject) + + messageDoc := NewHTMLParser(t, bytes.NewBuffer([]byte(msgs[0].Body))) + link, ok := messageDoc.Find("a").Attr("href") + assert.True(t, ok) + u, err := url.Parse(link) + require.NoError(t, err) + code = u.Query()["code"][0] + })() + + session := emptyTestSession(t) + req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/sign_up"), + "user_name": "doesnotexist", + "email": "doesnotexist@example.com", + "password": "examplePassword!1", + "retype": "examplePassword!1", + }) + session.MakeRequest(t, req, http.StatusOK) + assert.True(t, called) + + queryCode, err := url.QueryUnescape(code) + require.NoError(t, err) + + lookupKey, validator, ok := strings.Cut(queryCode, ":") + assert.True(t, ok) + + rawValidator, err := hex.DecodeString(validator) + require.NoError(t, err) + + authToken, err := auth_model.FindAuthToken(db.DefaultContext, lookupKey, auth_model.UserActivation) + require.NoError(t, err) + assert.False(t, authToken.IsExpired()) + assert.EqualValues(t, authToken.HashedValidator, auth_model.HashValidator(rawValidator)) + + req = NewRequest(t, "POST", "/user/activate?code="+code) + session.MakeRequest(t, req, http.StatusOK) + + unittest.AssertNotExistsBean(t, &auth_model.AuthorizationToken{ID: authToken.ID}) + unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "doesnotexist", IsActive: true}) +} + +func TestUserPasswordReset(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + called := false + code := "" + defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) { + if called { + return + } + called = true + + assert.Len(t, msgs, 1) + assert.Equal(t, user2.EmailTo(), msgs[0].To) + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.reset_password"), msgs[0].Subject) + + messageDoc := NewHTMLParser(t, bytes.NewBuffer([]byte(msgs[0].Body))) + link, ok := messageDoc.Find("a").Attr("href") + assert.True(t, ok) + u, err := url.Parse(link) + require.NoError(t, err) + code = u.Query()["code"][0] + })() + + session := emptyTestSession(t) + req := NewRequestWithValues(t, "POST", "/user/forgot_password", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/forgot_password"), + "email": user2.Email, + }) + session.MakeRequest(t, req, http.StatusOK) + assert.True(t, called) + + queryCode, err := url.QueryUnescape(code) + require.NoError(t, err) + + lookupKey, validator, ok := strings.Cut(queryCode, ":") + assert.True(t, ok) + + rawValidator, err := hex.DecodeString(validator) + require.NoError(t, err) + + authToken, err := auth_model.FindAuthToken(db.DefaultContext, lookupKey, auth_model.PasswordReset) + require.NoError(t, err) + assert.False(t, authToken.IsExpired()) + assert.EqualValues(t, authToken.HashedValidator, auth_model.HashValidator(rawValidator)) + + req = NewRequestWithValues(t, "POST", "/user/recover_account", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/recover_account"), + "code": code, + "password": "new_password", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + unittest.AssertNotExistsBean(t, &auth_model.AuthorizationToken{ID: authToken.ID}) + assert.True(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).ValidatePassword("new_password")) +} + +func TestActivateEmailAddress(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm, true)() + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + called := false + code := "" + defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) { + if called { + return + } + called = true + + assert.Len(t, msgs, 1) + assert.Equal(t, "newemail@example.org", msgs[0].To) + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.activate_email"), msgs[0].Subject) + + messageDoc := NewHTMLParser(t, bytes.NewBuffer([]byte(msgs[0].Body))) + link, ok := messageDoc.Find("a").Attr("href") + assert.True(t, ok) + u, err := url.Parse(link) + require.NoError(t, err) + code = u.Query()["code"][0] + })() + + session := loginUser(t, user2.Name) + req := NewRequestWithValues(t, "POST", "/user/settings/account/email", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings"), + "email": "newemail@example.org", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + assert.True(t, called) + + queryCode, err := url.QueryUnescape(code) + require.NoError(t, err) + + lookupKey, validator, ok := strings.Cut(queryCode, ":") + assert.True(t, ok) + + rawValidator, err := hex.DecodeString(validator) + require.NoError(t, err) + + authToken, err := auth_model.FindAuthToken(db.DefaultContext, lookupKey, auth_model.EmailActivation("newemail@example.org")) + require.NoError(t, err) + assert.False(t, authToken.IsExpired()) + assert.EqualValues(t, authToken.HashedValidator, auth_model.HashValidator(rawValidator)) + + req = NewRequestWithValues(t, "POST", "/user/activate_email", map[string]string{ + "code": code, + "email": "newemail@example.org", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + unittest.AssertNotExistsBean(t, &auth_model.AuthorizationToken{ID: authToken.ID}) + unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{UID: user2.ID, IsActivated: true, Email: "newemail@example.org"}) +} diff --git a/tests/integration/version_test.go b/tests/integration/version_test.go new file mode 100644 index 0000000..144471a --- /dev/null +++ b/tests/integration/version_test.go @@ -0,0 +1,62 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestVersion(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + t.Run("Version", func(t *testing.T) { + setting.AppVer = "test-version-1" + req := NewRequest(t, "GET", "/api/v1/version") + resp := MakeRequest(t, req, http.StatusOK) + + var version structs.ServerVersion + DecodeJSON(t, resp, &version) + assert.Equal(t, setting.AppVer, version.Version) + }) + + t.Run("Versions with REQUIRE_SIGNIN_VIEW enabled", func(t *testing.T) { + defer test.MockVariableValue(&setting.Service.RequireSignInView, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + setting.AppVer = "test-version-1" + + t.Run("Get version without auth", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // GET api without auth + req := NewRequest(t, "GET", "/api/v1/version") + MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("Get version without auth", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + username := "user1" + session := loginUser(t, username) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // GET api with auth + req := NewRequest(t, "GET", "/api/v1/version").AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + + var version structs.ServerVersion + DecodeJSON(t, resp, &version) + assert.Equal(t, setting.AppVer, version.Version) + }) + }) +} diff --git a/tests/integration/view_test.go b/tests/integration/view_test.go new file mode 100644 index 0000000..ff2f2bd --- /dev/null +++ b/tests/integration/view_test.go @@ -0,0 +1,212 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "testing" + + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + files_service "code.gitea.io/gitea/services/repository/files" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestRenderFileSVGIsInImgTag(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user2/repo2/src/branch/master/line.svg") + resp := session.MakeRequest(t, req, http.StatusOK) + + doc := NewHTMLParser(t, resp.Body) + src, exists := doc.doc.Find(".file-view img").Attr("src") + assert.True(t, exists, "The SVG image should be in an <img> tag so that scripts in the SVG are not run") + assert.Equal(t, "/user2/repo2/raw/branch/master/line.svg", src) +} + +func TestAmbiguousCharacterDetection(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + + // Prepare the environments. File view, commit view (diff), wiki page. + repo, commitID, f := tests.CreateDeclarativeRepo(t, user2, "", + []unit_model.Type{unit_model.TypeCode, unit_model.TypeWiki}, nil, + []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "test.sh", + ContentReader: strings.NewReader("Hello there!\nline western"), + }, + }, + ) + defer f() + + req := NewRequestWithValues(t, "POST", repo.Link()+"/wiki?action=new", map[string]string{ + "_csrf": GetCSRF(t, session, repo.Link()+"/wiki?action=new"), + "title": "Normal", + "content": "Hello – Hello", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + assertCase := func(t *testing.T, fileContext, commitContext, wikiContext bool) { + t.Helper() + + t.Run("File context", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", repo.Link()+"/src/branch/main/test.sh") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, ".unicode-escape-prompt", fileContext) + }) + t.Run("Commit context", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", repo.Link()+"/commit/"+commitID) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, ".lines-escape .toggle-escape-button", commitContext) + }) + t.Run("Wiki context", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", repo.Link()+"/wiki/Normal") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertElement(t, ".unicode-escape-prompt", wikiContext) + }) + } + + t.Run("Enabled all context", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.SkipEscapeContexts, []string{})() + + assertCase(t, true, true, true) + }) + + t.Run("Enabled file context", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.SkipEscapeContexts, []string{"diff", "wiki"})() + + assertCase(t, true, false, false) + }) + + t.Run("Enabled commit context", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.SkipEscapeContexts, []string{"file-view", "wiki"})() + + assertCase(t, false, true, false) + }) + + t.Run("Enabled wiki context", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.SkipEscapeContexts, []string{"file-view", "diff"})() + + assertCase(t, false, false, true) + }) + + t.Run("No context", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.SkipEscapeContexts, []string{"file-view", "wiki", "diff"})() + + assertCase(t, false, false, false) + }) + + t.Run("Disabled detection", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.SkipEscapeContexts, []string{})() + defer test.MockVariableValue(&setting.UI.AmbiguousUnicodeDetection, false)() + + assertCase(t, false, false, false) + }) + }) +} + +func TestInHistoryButton(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + repo, commitID, f := tests.CreateDeclarativeRepo(t, user2, "", + []unit_model.Type{unit_model.TypeCode, unit_model.TypeWiki}, nil, + []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: "test.sh", + ContentReader: strings.NewReader("Hello there!"), + }, + }, + ) + defer f() + + req := NewRequestWithValues(t, "POST", repo.Link()+"/wiki?action=new", map[string]string{ + "_csrf": GetCSRF(t, session, repo.Link()+"/wiki?action=new"), + "title": "Normal", + "content": "Hello world!", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + t.Run("Wiki revision", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", repo.Link()+"/wiki/Normal?action=_revision") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, fmt.Sprintf(".commit-list a[href^='/%s/src/commit/']", repo.FullName()), false) + }) + + t.Run("Commit list", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", repo.Link()+"/commits/branch/main") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, fmt.Sprintf(".commit-list a[href='/%s/src/commit/%s']", repo.FullName(), commitID), true) + }) + + t.Run("File history", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", repo.Link()+"/commits/branch/main/test.sh") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, fmt.Sprintf(".commit-list a[href='/%s/src/commit/%s/test.sh']", repo.FullName(), commitID), true) + }) + }) +} + +func TestTitleDisplayName(t *testing.T) { + session := emptyTestSession(t) + title := GetHTMLTitle(t, session, "/") + assert.Equal(t, "Forgejo: Beyond coding. We Forge.", title) +} + +func TestHomeDisplayName(t *testing.T) { + session := emptyTestSession(t) + req := NewRequest(t, "GET", "/") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Equal(t, "Forgejo: Beyond coding. We Forge.", strings.TrimSpace(htmlDoc.Find("h1.title").Text())) +} + +func TestOpenGraphDisplayName(t *testing.T) { + session := emptyTestSession(t) + req := NewRequest(t, "GET", "/") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + ogTitle, _ := htmlDoc.Find("meta[property='og:title']").Attr("content") + assert.Equal(t, "Forgejo: Beyond coding. We Forge.", ogTitle) + ogSiteName, _ := htmlDoc.Find("meta[property='og:site_name']").Attr("content") + assert.Equal(t, "Forgejo: Beyond coding. We Forge.", ogSiteName) +} diff --git a/tests/integration/webfinger_test.go b/tests/integration/webfinger_test.go new file mode 100644 index 0000000..18f509a --- /dev/null +++ b/tests/integration/webfinger_test.go @@ -0,0 +1,84 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestWebfinger(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + setting.Federation.Enabled = true + defer func() { + setting.Federation.Enabled = false + }() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + appURL, _ := url.Parse(setting.AppURL) + + type webfingerLink struct { + Rel string `json:"rel,omitempty"` + Type string `json:"type,omitempty"` + Href string `json:"href,omitempty"` + Titles map[string]string `json:"titles,omitempty"` + Properties map[string]any `json:"properties,omitempty"` + } + + type webfingerJRD struct { + Subject string `json:"subject,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Properties map[string]any `json:"properties,omitempty"` + Links []*webfingerLink `json:"links,omitempty"` + } + + session := loginUser(t, "user1") + + req := NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", user.LowerName, appURL.Host)) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "application/jrd+json", resp.Header().Get("Content-Type")) + + var jrd webfingerJRD + DecodeJSON(t, resp, &jrd) + assert.Equal(t, "acct:user2@"+appURL.Host, jrd.Subject) + assert.ElementsMatch(t, []string{user.HTMLURL(), appURL.String() + "api/v1/activitypub/user-id/" + fmt.Sprint(user.ID)}, jrd.Aliases) + + req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", user.LowerName, "unknown.host")) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", "user31", appURL.Host)) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", "user31", appURL.Host)) + session.MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=mailto:%s", user.Email)) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=https://%s/%s/", appURL.Host, user.Name)) + session.MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=https://%s/%s", appURL.Host, user.Name)) + session.MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=http://%s/%s/foo", appURL.Host, user.Name)) + session.MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=http://%s", appURL.Host)) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=http://%s/%s/foo", "example.com", user.Name)) + MakeRequest(t, req, http.StatusBadRequest) +} diff --git a/tests/integration/webhook_test.go b/tests/integration/webhook_test.go new file mode 100644 index 0000000..60d4d48 --- /dev/null +++ b/tests/integration/webhook_test.go @@ -0,0 +1,189 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/json" + webhook_module "code.gitea.io/gitea/modules/webhook" + "code.gitea.io/gitea/services/release" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWebhookPayloadRef(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + w := unittest.AssertExistsAndLoadBean(t, &webhook_model.Webhook{ID: 1}) + w.HookEvent = &webhook_module.HookEvent{ + SendEverything: true, + } + require.NoError(t, w.UpdateEvent()) + require.NoError(t, webhook_model.UpdateWebhook(db.DefaultContext, w)) + + hookTasks := retrieveHookTasks(t, w.ID, true) + hookTasksLenBefore := len(hookTasks) + + session := loginUser(t, "user2") + // create new branch + csrf := GetCSRF(t, session, "user2/repo1") + req := NewRequestWithValues(t, "POST", "user2/repo1/branches/_new/branch/master", + map[string]string{ + "_csrf": csrf, + "new_branch_name": "arbre", + "create_tag": "false", + }, + ) + session.MakeRequest(t, req, http.StatusSeeOther) + // delete the created branch + req = NewRequestWithValues(t, "POST", "user2/repo1/branches/delete?name=arbre", + map[string]string{ + "_csrf": csrf, + }, + ) + session.MakeRequest(t, req, http.StatusOK) + + // check the newly created hooktasks + hookTasks = retrieveHookTasks(t, w.ID, false) + expected := map[webhook_module.HookEventType]bool{ + webhook_module.HookEventCreate: true, + webhook_module.HookEventPush: true, // the branch creation also creates a push event + webhook_module.HookEventDelete: true, + } + for _, hookTask := range hookTasks[:len(hookTasks)-hookTasksLenBefore] { + if !expected[hookTask.EventType] { + t.Errorf("unexpected (or duplicated) event %q", hookTask.EventType) + } + + var payload struct { + Ref string `json:"ref"` + } + require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payload)) + assert.Equal(t, "refs/heads/arbre", payload.Ref, "unexpected ref for %q event", hookTask.EventType) + delete(expected, hookTask.EventType) + } + assert.Empty(t, expected) + }) +} + +func TestWebhookReleaseEvents(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + w := unittest.AssertExistsAndLoadBean(t, &webhook_model.Webhook{ + ID: 1, + RepoID: repo.ID, + }) + w.HookEvent = &webhook_module.HookEvent{ + SendEverything: true, + } + require.NoError(t, w.UpdateEvent()) + require.NoError(t, webhook_model.UpdateWebhook(db.DefaultContext, w)) + + hookTasks := retrieveHookTasks(t, w.ID, true) + + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo) + require.NoError(t, err) + defer gitRepo.Close() + + t.Run("CreateRelease", func(t *testing.T) { + require.NoError(t, release.CreateRelease(gitRepo, &repo_model.Release{ + RepoID: repo.ID, + Repo: repo, + PublisherID: user.ID, + Publisher: user, + TagName: "v1.1.1", + Target: "master", + Title: "v1.1.1 is released", + Note: "v1.1.1 is released", + IsDraft: false, + IsPrerelease: false, + IsTag: false, + }, "", nil)) + + // check the newly created hooktasks + hookTasksLenBefore := len(hookTasks) + hookTasks = retrieveHookTasks(t, w.ID, false) + + checkHookTasks(t, map[webhook_module.HookEventType]string{ + webhook_module.HookEventRelease: "published", + webhook_module.HookEventCreate: "", // a tag was created as well + webhook_module.HookEventPush: "", // the tag creation also means a push event + }, hookTasks[:len(hookTasks)-hookTasksLenBefore]) + + t.Run("UpdateRelease", func(t *testing.T) { + rel := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{RepoID: repo.ID, TagName: "v1.1.1"}) + require.NoError(t, release.UpdateRelease(db.DefaultContext, user, gitRepo, rel, false, nil)) + + // check the newly created hooktasks + hookTasksLenBefore := len(hookTasks) + hookTasks = retrieveHookTasks(t, w.ID, false) + + checkHookTasks(t, map[webhook_module.HookEventType]string{ + webhook_module.HookEventRelease: "updated", + }, hookTasks[:len(hookTasks)-hookTasksLenBefore]) + }) + }) + + t.Run("CreateNewTag", func(t *testing.T) { + require.NoError(t, release.CreateNewTag(db.DefaultContext, + user, + repo, + "master", + "v1.1.2", + "v1.1.2 is tagged", + )) + + // check the newly created hooktasks + hookTasksLenBefore := len(hookTasks) + hookTasks = retrieveHookTasks(t, w.ID, false) + + checkHookTasks(t, map[webhook_module.HookEventType]string{ + webhook_module.HookEventCreate: "", // tag was created as well + webhook_module.HookEventPush: "", // the tag creation also means a push event + }, hookTasks[:len(hookTasks)-hookTasksLenBefore]) + + t.Run("UpdateRelease", func(t *testing.T) { + rel := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{RepoID: repo.ID, TagName: "v1.1.2"}) + require.NoError(t, release.UpdateRelease(db.DefaultContext, user, gitRepo, rel, true, nil)) + + // check the newly created hooktasks + hookTasksLenBefore := len(hookTasks) + hookTasks = retrieveHookTasks(t, w.ID, false) + + checkHookTasks(t, map[webhook_module.HookEventType]string{ + webhook_module.HookEventRelease: "published", + }, hookTasks[:len(hookTasks)-hookTasksLenBefore]) + }) + }) +} + +func checkHookTasks(t *testing.T, expectedActions map[webhook_module.HookEventType]string, hookTasks []*webhook_model.HookTask) { + t.Helper() + for _, hookTask := range hookTasks { + expectedAction, ok := expectedActions[hookTask.EventType] + if !ok { + t.Errorf("unexpected (or duplicated) event %q", hookTask.EventType) + } + var payload struct { + Action string `json:"action"` + } + require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payload)) + assert.Equal(t, expectedAction, payload.Action, "unexpected action for %q event", hookTask.EventType) + delete(expectedActions, hookTask.EventType) + } + assert.Empty(t, expectedActions) +} diff --git a/tests/integration/xss_test.go b/tests/integration/xss_test.go new file mode 100644 index 0000000..70038cf --- /dev/null +++ b/tests/integration/xss_test.go @@ -0,0 +1,129 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/tests" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestXSSUserFullName(t *testing.T) { + defer tests.PrepareTestEnv(t)() + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + const fullName = `name & <script class="evil">alert('Oh no!');</script>` + + session := loginUser(t, user.Name) + req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/settings"), + "name": user.Name, + "full_name": fullName, + "email": user.Email, + "language": "en-US", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + req = NewRequestf(t, "GET", "/%s", user.Name) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + assert.EqualValues(t, 0, htmlDoc.doc.Find("script.evil").Length()) + assert.EqualValues(t, fullName, + htmlDoc.doc.Find("div.content").Find(".header.text.center").Text(), + ) +} + +func TestXSSWikiLastCommitInfo(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + // Prepare the environment. + dstPath := t.TempDir() + r := fmt.Sprintf("%suser2/repo1.wiki.git", u.String()) + u, err := url.Parse(r) + require.NoError(t, err) + u.User = url.UserPassword("user2", userPassword) + require.NoError(t, git.CloneWithArgs(context.Background(), git.AllowLFSFiltersArgs(), u.String(), dstPath, git.CloneRepoOptions{})) + + // Use go-git here, because using git wouldn't work, it has code to remove + // `<`, `>` and `\n` in user names. Even though this is permitted and + // wouldn't result in a error by a Git server. + gitRepo, err := gogit.PlainOpen(dstPath) + require.NoError(t, err) + + w, err := gitRepo.Worktree() + require.NoError(t, err) + + filename := filepath.Join(dstPath, "Home.md") + err = os.WriteFile(filename, []byte("Oh, a XSS attack?"), 0o644) + require.NoError(t, err) + + _, err = w.Add("Home.md") + require.NoError(t, err) + + _, err = w.Commit("Yay XSS", &gogit.CommitOptions{ + Author: &object.Signature{ + Name: `Gusted<script class="evil">alert('Oh no!');</script>`, + Email: "valid@example.org", + When: time.Date(2024, time.January, 31, 0, 0, 0, 0, time.UTC), + }, + }) + require.NoError(t, err) + + // Push. + _, _, err = git.NewCommand(git.DefaultContext, "push").AddArguments(git.ToTrustedCmdArgs([]string{"origin", "master"})...).RunStdString(&git.RunOpts{Dir: dstPath}) + require.NoError(t, err) + + // Check on page view. + t.Run("Page view", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, http.MethodGet, "/user2/repo1/wiki/Home") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, "script.evil", false) + assert.Contains(t, htmlDoc.Find(".ui.sub.header").Text(), `Gusted<script class="evil">alert('Oh no!');</script> edited this page 2024-01-31`) + }) + + // Check on revisions page. + t.Run("Revision page", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, http.MethodGet, "/user2/repo1/wiki/Home?action=_revision") + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, "script.evil", false) + assert.Contains(t, htmlDoc.Find(".ui.sub.header").Text(), `Gusted<script class="evil">alert('Oh no!');</script> edited this page 2024-01-31`) + }) + }) +} + +func TestXSSReviewDismissed(t *testing.T) { + defer tests.AddFixtures("tests/integration/fixtures/TestXSSReviewDismissed/")() + defer tests.PrepareTestEnv(t)() + + review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 1000}) + + req := NewRequest(t, http.MethodGet, fmt.Sprintf("/user2/repo1/pulls/%d", +review.IssueID)) + resp := MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + htmlDoc.AssertElement(t, "script.evil", false) + assert.Contains(t, htmlDoc.Find("#issuecomment-1000 .dismissed-message").Text(), `dismissed Otto <script class='evil'>alert('Oh no!')</script>'s review`) +} |