diff --git a/internal/cli/main.go b/internal/cli/main.go index a9192c4..1fbd009 100644 --- a/internal/cli/main.go +++ b/internal/cli/main.go @@ -2,6 +2,12 @@ package cli import ( "fmt" + "os" + "os/signal" + "path" + "path/filepath" + "syscall" + "github.com/rs/zerolog/log" "github.com/thomiceli/opengist/internal/auth/webauthn" "github.com/thomiceli/opengist/internal/config" @@ -12,11 +18,6 @@ import ( "github.com/thomiceli/opengist/internal/web/handlers/metrics" "github.com/thomiceli/opengist/internal/web/server" "github.com/urfave/cli/v2" - "os" - "os/signal" - "path" - "path/filepath" - "syscall" ) var CmdVersion = cli.Command{ @@ -37,7 +38,7 @@ var CmdStart = cli.Command{ Initialize(ctx) - httpServer := server.NewServer(os.Getenv("OG_DEV") == "1", path.Join(config.GetHomeDir(), "sessions"), false) + httpServer := server.NewServer(os.Getenv("OG_DEV") == "1") go httpServer.Start() go ssh.Start() diff --git a/internal/config/config.go b/internal/config/config.go index 78a4dd9..6b3f83e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,7 +2,6 @@ package config import ( "fmt" - "github.com/thomiceli/opengist/internal/session" "io" "net/url" "os" @@ -13,6 +12,8 @@ import ( "strings" "time" + "github.com/thomiceli/opengist/internal/session" + "github.com/rs/zerolog" "github.com/rs/zerolog/log" "gopkg.in/yaml.v3" diff --git a/internal/web/handlers/admin/actions_test.go b/internal/web/handlers/admin/actions_test.go new file mode 100644 index 0000000..65cefdb --- /dev/null +++ b/internal/web/handlers/admin/actions_test.go @@ -0,0 +1,46 @@ +package admin_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + webtest "github.com/thomiceli/opengist/internal/web/test" +) + +func TestAdminActions(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + urls := []string{ + "/admin-panel/sync-fs", + "/admin-panel/sync-db", + "/admin-panel/gc-repos", + "/admin-panel/sync-previews", + "/admin-panel/reset-hooks", + "/admin-panel/index-gists", + "/admin-panel/sync-languages", + } + + s.Register(t, "thomas") + s.Register(t, "nonadmin") + + t.Run("NoUser", func(t *testing.T) { + for _, url := range urls { + s.Request(t, "POST", url, nil, 404) + } + }) + + t.Run("AdminUser", func(t *testing.T) { + s.Login(t, "thomas") + for _, url := range urls { + resp := s.Request(t, "POST", url, nil, 302) + require.Equal(t, "/admin-panel", resp.Header.Get("Location")) + } + }) + + t.Run("NonAdminUser", func(t *testing.T) { + s.Login(t, "nonadmin") + for _, url := range urls { + s.Request(t, "POST", url, nil, 404) + } + }) +} diff --git a/internal/web/handlers/admin/admin_test.go b/internal/web/handlers/admin/admin_test.go new file mode 100644 index 0000000..ad06bdd --- /dev/null +++ b/internal/web/handlers/admin/admin_test.go @@ -0,0 +1,269 @@ +package admin_test + +import ( + "net/url" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/config" + "github.com/thomiceli/opengist/internal/db" + "github.com/thomiceli/opengist/internal/git" + webtest "github.com/thomiceli/opengist/internal/web/test" +) + +func TestAdminPages(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + urls := []string{ + "/admin-panel", + "/admin-panel/users", + "/admin-panel/gists", + "/admin-panel/invitations", + "/admin-panel/configuration", + } + + s.Register(t, "thomas") + s.Register(t, "nonadmin") + + t.Run("NoUser", func(t *testing.T) { + for _, url := range urls { + s.Request(t, "GET", url, nil, 404) + } + }) + + t.Run("AdminUser", func(t *testing.T) { + s.Login(t, "thomas") + for _, url := range urls { + s.Request(t, "GET", url, nil, 200) + } + }) + + t.Run("NonAdminUser", func(t *testing.T) { + s.Login(t, "nonadmin") + for _, url := range urls { + s.Request(t, "GET", url, nil, 404) + } + }) +} + +func TestAdminSetConfig(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + settings := []string{ + db.SettingDisableSignup, + db.SettingRequireLogin, + db.SettingAllowGistsWithoutLogin, + db.SettingDisableLoginForm, + db.SettingDisableGravatar, + } + + s.Register(t, "thomas") + s.Register(t, "nonadmin") + + t.Run("NoUser", func(t *testing.T) { + s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404) + }) + + t.Run("NonAdminUser", func(t *testing.T) { + s.Login(t, "nonadmin") + s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404) + }) + + t.Run("AdminUser", func(t *testing.T) { + s.Login(t, "thomas") + + for _, setting := range settings { + val, err := db.GetSetting(setting) + require.NoError(t, err) + require.Equal(t, "0", val) + + s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {setting}, "value": {"1"}}, 200) + + val, err = db.GetSetting(setting) + require.NoError(t, err) + require.Equal(t, "1", val) + + s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {setting}, "value": {"0"}}, 200) + + val, err = db.GetSetting(setting) + require.NoError(t, err) + require.Equal(t, "0", val) + } + }) +} + +func TestAdminPagination(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + for i := 0; i < 11; i++ { + s.Register(t, "user"+strconv.Itoa(i)) + } + + t.Run("Pagination", func(t *testing.T) { + s.Login(t, "thomas") + + s.Request(t, "GET", "/admin-panel/users", nil, 200) + s.Request(t, "GET", "/admin-panel/users?page=2", nil, 200) + s.Request(t, "GET", "/admin-panel/users?page=3", nil, 404) + s.Request(t, "GET", "/admin-panel/users?page=0", nil, 200) + s.Request(t, "GET", "/admin-panel/users?page=-1", nil, 200) + s.Request(t, "GET", "/admin-panel/users?page=a", nil, 200) + }) +} + +func TestAdminUserOperations(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + s.Register(t, "nonadmin") + + t.Run("DeleteUser", func(t *testing.T) { + s.Login(t, "nonadmin") + + gist1 := db.GistDTO{ + Title: "gist", + VisibilityDTO: db.VisibilityDTO{ + Private: 0, + }, + Name: []string{"gist1.txt"}, + Content: []string{"yeah"}, + Topics: "", + } + s.Request(t, "POST", "/", gist1, 302) + + _, err := os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, "nonadmin")) + require.NoError(t, err) + + count, err := db.CountAll(db.User{}) + require.NoError(t, err) + require.Equal(t, int64(2), count) + + s.Request(t, "POST", "/admin-panel/users/2/delete", nil, 404) + + s.Login(t, "thomas") + + s.Request(t, "POST", "/admin-panel/users/2/delete", nil, 302) + + count, err = db.CountAll(db.User{}) + require.NoError(t, err) + require.Equal(t, int64(1), count) + + _, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, "nonadmin")) + require.Error(t, err) + }) +} + +func TestAdminGistOperations(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + s.Register(t, "nonadmin") + + t.Run("DeleteGist", func(t *testing.T) { + s.Login(t, "nonadmin") + gist1 := db.GistDTO{ + Title: "gist", + VisibilityDTO: db.VisibilityDTO{ + Private: 0, + }, + Name: []string{"gist1.txt"}, + Content: []string{"yeah"}, + Topics: "", + } + s.Request(t, "POST", "/", gist1, 302) + + count, err := db.CountAll(db.Gist{}) + require.NoError(t, err) + require.Equal(t, int64(1), count) + + gist1Db, err := db.GetGistByID("1") + require.NoError(t, err) + + _, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, "nonadmin", gist1Db.Identifier())) + require.NoError(t, err) + + s.Request(t, "POST", "/admin-panel/gists/1/delete", nil, 404) + + s.Login(t, "thomas") + + s.Request(t, "POST", "/admin-panel/gists/1/delete", nil, 302) + + count, err = db.CountAll(db.Gist{}) + require.NoError(t, err) + require.Equal(t, int64(0), count) + + _, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, "nonadmin", gist1Db.Identifier())) + require.Error(t, err) + }) +} + +func TestAdminInvitationOperations(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + s.Register(t, "nonadmin") + + t.Run("Invitation", func(t *testing.T) { + s.Login(t, "thomas") + + s.Request(t, "POST", "/admin-panel/invitations", url.Values{ + "nbMax": {""}, + "expiredAtUnix": {""}, + }, 302) + invitation1, err := db.GetInvitationByID(1) + require.NoError(t, err) + require.Equal(t, uint(1), invitation1.ID) + require.Equal(t, uint(0), invitation1.NbUsed) + require.Equal(t, uint(10), invitation1.NbMax) + require.InDelta(t, time.Now().Unix()+604800, invitation1.ExpiresAt, 10) + + s.Request(t, "POST", "/admin-panel/invitations", url.Values{ + "nbMax": {"aa"}, + "expiredAtUnix": {"1735722000"}, + }, 302) + invitation2, err := db.GetInvitationByID(2) + require.NoError(t, err) + require.Equal(t, invitation2, &db.Invitation{ + ID: 2, + Code: invitation2.Code, + ExpiresAt: time.Unix(1735722000, 0).Unix(), + NbUsed: 0, + NbMax: 10, + }) + + s.Request(t, "POST", "/admin-panel/invitations", url.Values{ + "nbMax": {"20"}, + "expiredAtUnix": {"1735722000"}, + }, 302) + invitation3, err := db.GetInvitationByID(3) + require.NoError(t, err) + require.Equal(t, invitation3, &db.Invitation{ + ID: 3, + Code: invitation3.Code, + ExpiresAt: time.Unix(1735722000, 0).Unix(), + NbUsed: 0, + NbMax: 20, + }) + + count, err := db.CountAll(db.Invitation{}) + require.NoError(t, err) + require.Equal(t, int64(3), count) + + s.Request(t, "POST", "/admin-panel/invitations/1/delete", nil, 302) + + count, err = db.CountAll(db.Invitation{}) + require.NoError(t, err) + require.Equal(t, int64(2), count) + }) +} diff --git a/internal/web/handlers/auth/auth_test.go b/internal/web/handlers/auth/auth_test.go new file mode 100644 index 0000000..426ea7b --- /dev/null +++ b/internal/web/handlers/auth/auth_test.go @@ -0,0 +1 @@ +package auth_test diff --git a/internal/web/handlers/auth/password_test.go b/internal/web/handlers/auth/password_test.go new file mode 100644 index 0000000..3d606c8 --- /dev/null +++ b/internal/web/handlers/auth/password_test.go @@ -0,0 +1,221 @@ +package auth_test + +import ( + "net/url" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/db" + webtest "github.com/thomiceli/opengist/internal/web/test" +) + +func TestRegisterPage(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + s.Register(t, "thomas") + + t.Run("Form", func(t *testing.T) { + s.Request(t, "GET", "/register", nil, 200) + s.TestCtxData(t, echo.Map{ + "isLoginPage": false, + "disableForm": false, + "disableSignup": false, + }) + }) + + t.Run("FormDisabled", func(t *testing.T) { + s.Login(t, "thomas") + s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) + s.Logout() + + s.Request(t, "GET", "/register", nil, 200) + s.TestCtxData(t, echo.Map{ + "disableSignup": true, + }) + }) + + t.Run("FormDisabledWithInviteCode", func(t *testing.T) { + s.Login(t, "thomas") + s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) + + s.Request(t, "POST", "/admin-panel/invitations", url.Values{ + "nbMax": {"10"}, + "expiredAtUnix": {""}, + }, 302) + + invitation, err := db.GetInvitationByID(1) + require.NoError(t, err) + + s.Logout() + + s.Request(t, "GET", "/register", nil, 200) + s.TestCtxData(t, echo.Map{ + "disableSignup": true, + }) + s.Request(t, "GET", "/register?code="+invitation.Code, nil, 200) + s.TestCtxData(t, echo.Map{ + "disableSignup": false, + }) + }) +} + +func TestProcessRegister(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + s.Register(t, "alice") + + t.Run("Register", func(t *testing.T) { + user, err := db.GetUserByUsername("thomas") + require.NoError(t, err) + require.True(t, user.IsAdmin) + s.Logout() + + s.Request(t, "POST", "/register", db.UserDTO{Username: "seconduser", Password: "password123"}, 302) + user, err = db.GetUserByUsername("seconduser") + require.NoError(t, err) + require.False(t, user.IsAdmin) + s.Logout() + }) + + t.Run("DuplicateUsername", func(t *testing.T) { + s.Request(t, "POST", "/register", db.UserDTO{Username: "useraaa", Password: "password123"}, 302) + s.Logout() + s.Request(t, "POST", "/register", db.UserDTO{Username: "useraaa", Password: "password456"}, 200) + s.Logout() + }) + + t.Run("InvalidUsername", func(t *testing.T) { + s.Request(t, "POST", "/register", db.UserDTO{Username: "", Password: "password123"}, 200) + s.Request(t, "POST", "/register", db.UserDTO{Username: "aze@", Password: "password123"}, 200) + }) + + t.Run("EmptyPassword", func(t *testing.T) { + s.Request(t, "POST", "/register", db.UserDTO{Username: "newuser", Password: ""}, 200) + }) + + t.Run("RegisterDisabled", func(t *testing.T) { + s.Login(t, "thomas") + s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) + s.Logout() + + s.Request(t, "POST", "/register", db.UserDTO{Username: "blocked", Password: "password123"}, 403) + + exists, err := db.UserExists("blocked") + require.NoError(t, err) + require.False(t, exists) + }) + + t.Run("RegisterWithInvitationCode", func(t *testing.T) { + s.Login(t, "thomas") + s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) + s.Request(t, "POST", "/admin-panel/invitations", url.Values{ + "nbMax": {"10"}, + "expiredAtUnix": {""}, + }, 302) + s.Logout() + + invitations, err := db.GetAllInvitations() + require.NoError(t, err) + require.NotEmpty(t, invitations) + invitation := invitations[len(invitations)-1] + + s.Logout() + + s.Request(t, "POST", "/register?code="+invitation.Code, db.UserDTO{Username: "inviteduser", Password: "password123"}, 302) + + user, err := db.GetUserByUsername("inviteduser") + require.NoError(t, err) + require.Equal(t, "inviteduser", user.Username) + + updatedInvitation, err := db.GetInvitationByID(invitation.ID) + require.NoError(t, err) + require.Equal(t, uint(1), updatedInvitation.NbUsed) + }) +} + +func TestLoginPage(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + s.Register(t, "thomas") + + t.Run("Form", func(t *testing.T) { + s.Request(t, "GET", "/login", nil, 200) + s.TestCtxData(t, echo.Map{ + "isLoginPage": true, + "disableForm": false, + }) + }) + + t.Run("FormDisabled", func(t *testing.T) { + s.Login(t, "thomas") + s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200) + s.Logout() + + s.Request(t, "GET", "/login", nil, 200) + s.TestCtxData(t, echo.Map{ + "disableForm": true, + }) + }) +} + +func TestProcessLogin(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + + t.Run("ValidCredentials", func(t *testing.T) { + resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 302) + require.Equal(t, "/", resp.Header.Get("Location")) + require.NotEmpty(t, s.SessionCookie) + require.Equal(t, "thomas", s.User().Username) + + s.Logout() + }) + + t.Run("InvalidPassword", func(t *testing.T) { + resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "wrongpassword"}, 302) + require.Equal(t, "/login", resp.Header.Get("Location")) + require.Nil(t, s.User()) + }) + + t.Run("NonExistentUser", func(t *testing.T) { + resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "nonexistent", Password: "password"}, 302) + require.Equal(t, "/login", resp.Header.Get("Location")) + require.Nil(t, s.User()) + }) + + t.Run("EmptyCredentials", func(t *testing.T) { + s.Request(t, "POST", "/login", db.UserDTO{Username: "", Password: ""}, 302) + require.Nil(t, s.User()) + }) + + t.Run("LoginFormDisabled", func(t *testing.T) { + s.Login(t, "thomas") + s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200) + s.Logout() + + s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 403) + require.Nil(t, s.User()) + }) +} + +func TestLogout(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + + t.Run("LogoutRedirects", func(t *testing.T) { + s.Login(t, "thomas") + require.Equal(t, "thomas", s.User().Username) + + resp := s.Request(t, "GET", "/logout", nil, 302) + require.Equal(t, "/all", resp.Header.Get("Location")) + require.Nil(t, s.User()) + s.Request(t, "GET", "/", nil, 302) + }) +} diff --git a/internal/web/handlers/gist/create_test.go b/internal/web/handlers/gist/create_test.go new file mode 100644 index 0000000..8706f70 --- /dev/null +++ b/internal/web/handlers/gist/create_test.go @@ -0,0 +1,519 @@ +package gist_test + +import ( + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/config" + "github.com/thomiceli/opengist/internal/db" + "github.com/thomiceli/opengist/internal/git" + webtest "github.com/thomiceli/opengist/internal/web/test" +) + +// Helper function to extract username and gist identifier from redirect URL +func getGistInfoFromRedirect(resp *http.Response) (username, identifier string) { + location := resp.Header.Get("Location") + // Location format: /{username}/{identifier} + parts := strings.Split(strings.TrimPrefix(location, "/"), "/") + if len(parts) >= 2 { + return parts[0], parts[1] + } + return "", "" +} + +func verifyGistCreation(t *testing.T, gist *db.Gist, username, identifier string) { + require.NotNil(t, gist) + require.Equal(t, username, gist.User.Username) + require.Equal(t, identifier, gist.Identifier()) + require.NotEmpty(t, gist.Uuid) + require.Greater(t, gist.NbFiles, 0) + + gistPath := filepath.Join(config.GetHomeDir(), git.ReposDirectory, username, gist.Uuid) + _, err := os.Stat(gistPath) + require.NoError(t, err, "Gist repository should exist on filesystem at %s", gistPath) +} + +func TestGistCreationPage(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + + t.Run("NoAuth", func(t *testing.T) { + s.Request(t, "GET", "/", nil, 302) + }) + + t.Run("Authenticated", func(t *testing.T) { + s.Login(t, "thomas") + s.Request(t, "GET", "/", nil, 200) + }) +} + +func TestGistCreation(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + + t.Run("NoAuth", func(t *testing.T) { + s.Request(t, "POST", "/", url.Values{ + "title": {"Test Gist"}, + "name": {"test.txt"}, + "content": {"hello world"}, + }, 302) // Redirects to login + + // Verify no gist was created + count, err := db.CountAll(db.Gist{}) + require.NoError(t, err) + require.Equal(t, int64(0), count) + }) + + tests := []struct { + name string + data url.Values + expectedCode int + expectGistCreated bool + expectedTitle string + expectedDescription string + expectedURL string + expectedTopics string // Expected topics string + expectedNbFiles int + expectedVisibility db.Visibility + expectedFileNames []string // Expected filenames in the gist + expectedFileContents map[string]string // Expected content for each file (filename -> content) + }{ + { + name: "NoFiles", + data: url.Values{ + "title": {"Test Gist"}, + }, + expectedCode: 400, + expectGistCreated: false, + }, + { + name: "EmptyContent", + data: url.Values{ + "title": {"Test Gist"}, + "name": {"test.txt"}, + "content": {""}, + }, + expectedCode: 400, + expectGistCreated: false, + }, + { + name: "TitleTooLong", + data: url.Values{ + "title": {strings.Repeat("a", 251)}, // Max is 250 + "name": {"test.txt"}, + "content": {"hello"}, + }, + expectedCode: 400, + expectGistCreated: false, + }, + { + name: "DescriptionTooLong", + data: url.Values{ + "title": {"Test Gist"}, + "description": {strings.Repeat("a", 1001)}, // Max is 1000 + "name": {"test.txt"}, + "content": {"hello"}, + }, + expectedCode: 400, + expectGistCreated: false, + }, + { + name: "URLTooLong", + data: url.Values{ + "title": {"Test Gist"}, + "url": {strings.Repeat("a", 33)}, // Max is 32 + "name": {"test.txt"}, + "content": {"hello"}, + }, + expectedCode: 400, + expectGistCreated: false, + }, + { + name: "URLInvalidCharacters", + data: url.Values{ + "title": {"Test Gist"}, + "url": {"invalid@url#here"}, // Only alphanumeric and dashes allowed + "name": {"test.txt"}, + "content": {"hello"}, + }, + expectedCode: 400, + expectGistCreated: false, + }, + { + name: "InvalidVisibility", + data: url.Values{ + "title": {"Test Gist"}, + "name": {"test.txt"}, + "content": {"hello"}, + "private": {"3"}, // Valid values are 0, 1, 2 + }, + expectedCode: 400, + expectGistCreated: false, + }, + { + name: "Valid", + data: url.Values{ + "title": {"My Test Gist"}, + "name": {"test.txt"}, + "url": {"my-custom-url-123"}, // Alphanumeric + dashes should be valid + "content": {"hello world"}, + "private": {"0"}, + }, + expectedCode: 302, + expectGistCreated: true, + expectedTitle: "My Test Gist", + expectedNbFiles: 1, + expectedVisibility: db.PublicVisibility, + expectedFileNames: []string{"test.txt"}, + expectedFileContents: map[string]string{ + "test.txt": "hello world", + }, + }, + { + name: "AutoNamedFile", + data: url.Values{ + "title": {"Auto Named"}, + "name": {""}, + "content": {"content without name"}, + "private": {"0"}, + }, + expectedCode: 302, + expectGistCreated: true, + expectedTitle: "Auto Named", + expectedNbFiles: 1, + expectedVisibility: db.PublicVisibility, + expectedFileNames: []string{"gistfile1.txt"}, + expectedFileContents: map[string]string{ + "gistfile1.txt": "content without name", + }, + }, + { + name: "MultipleFiles", + data: url.Values{ + "title": {"Multi File Gist"}, + "name": []string{"", "file2.md"}, + "content": []string{"content 1", "content 2"}, + "private": {"0"}, + }, + expectedCode: 302, + expectGistCreated: true, + expectedTitle: "Multi File Gist", + expectedNbFiles: 2, + expectedVisibility: db.PublicVisibility, + expectedFileNames: []string{"gistfile1.txt", "file2.md"}, + expectedFileContents: map[string]string{ + "gistfile1.txt": "content 1", + "file2.md": "content 2", + }, + }, + { + name: "NoTitle", + data: url.Values{ + "name": {"readme.md"}, + "content": {"# README"}, + "private": {"0"}, + }, + expectedCode: 302, + expectGistCreated: true, + expectedTitle: "readme.md", + expectedNbFiles: 1, + expectedVisibility: db.PublicVisibility, + expectedFileNames: []string{"readme.md"}, + expectedFileContents: map[string]string{ + "readme.md": "# README", + }, + }, + { + name: "Unlisted", + data: url.Values{ + "title": {"Unlisted Gist"}, + "name": {"secret.txt"}, + "content": {"secret content"}, + "private": {"1"}, + }, + expectedCode: 302, + expectGistCreated: true, + expectedTitle: "Unlisted Gist", + expectedNbFiles: 1, + expectedVisibility: db.UnlistedVisibility, + expectedFileNames: []string{"secret.txt"}, + expectedFileContents: map[string]string{ + "secret.txt": "secret content", + }, + }, + { + name: "Private", + data: url.Values{ + "title": {"Private Gist"}, + "name": {"secret.txt"}, + "content": {"secret content"}, + "private": {"2"}, + }, + expectedCode: 302, + expectGistCreated: true, + expectedTitle: "Private Gist", + expectedNbFiles: 1, + expectedVisibility: db.PrivateVisibility, + expectedFileNames: []string{"secret.txt"}, + expectedFileContents: map[string]string{ + "secret.txt": "secret content", + }, + }, + { + name: "Topics", + data: url.Values{ + "title": {"Gist With Topics"}, + "name": {"test.txt"}, + "content": {"hello"}, + "topics": {"golang testing webdev"}, + }, + expectedCode: 302, + expectGistCreated: true, + expectedTitle: "Gist With Topics", + expectedTopics: "golang,testing,webdev", + expectedNbFiles: 1, + expectedVisibility: db.PublicVisibility, + expectedFileNames: []string{"test.txt"}, + expectedFileContents: map[string]string{ + "test.txt": "hello", + }, + }, + { + name: "TopicsTooMany", + data: url.Values{ + "title": {"Test"}, + "name": {"test.txt"}, + "content": {"hello"}, + "topics": {"topic1 topic2 topic3 topic4 topic5 topic6 topic7 topic8 topic9 topic10 topic11"}, + }, + expectedCode: 400, + expectGistCreated: false, + }, + { + name: "TopicTooLong", + data: url.Values{ + "title": {"Test"}, + "name": {"test.txt"}, + "content": {"hello"}, + "topics": {strings.Repeat("a", 51)}, // 51 chars - exceeds max of 50 + }, + expectedCode: 400, + expectGistCreated: false, + }, + { + name: "TopicInvalidCharacters", + data: url.Values{ + "title": {"Test"}, + "name": {"test.txt"}, + "content": {"hello"}, + "topics": {"topic@name topic.name"}, + }, + expectedCode: 400, + expectGistCreated: false, + }, + { + name: "TopicUnicode", + data: url.Values{ + "title": {"Unicode Topics"}, + "name": {"test.txt"}, + "content": {"hello"}, + "topics": {"编程 тест"}, + }, + expectedCode: 302, + expectGistCreated: true, + expectedTitle: "Unicode Topics", + expectedTopics: "编程,тест", + expectedNbFiles: 1, + expectedVisibility: db.PublicVisibility, + expectedFileNames: []string{"test.txt"}, + }, + { + name: "DuplicateFileNames", + data: url.Values{ + "title": {"Duplicate Files"}, + "name": []string{"test.txt", "test.txt"}, + "content": []string{"content1", "content2"}, + }, + expectedCode: 302, + expectGistCreated: true, + expectedTitle: "Duplicate Files", + expectedNbFiles: 1, + expectedVisibility: db.PublicVisibility, + expectedFileNames: []string{"test.txt"}, + expectedFileContents: map[string]string{ + "test.txt": "content2", + }, + }, + { + name: "FileNameTooLong", + data: url.Values{ + "title": {"Too Long Filename"}, + "name": {strings.Repeat("a", 256) + ".txt"}, // 260 total - exceeds 255 + "content": {"hello"}, + }, + expectedCode: 400, + expectGistCreated: false, + }, + { + name: "FileNameWithUnicode", + data: url.Values{ + "title": {"Unicode Filename"}, + "name": {"文件.txt"}, + "content": {"hello world"}, + }, + expectedCode: 302, + expectGistCreated: true, + expectedTitle: "Unicode Filename", + expectedNbFiles: 1, + expectedVisibility: db.PublicVisibility, + expectedFileNames: []string{"文件.txt"}, + expectedFileContents: map[string]string{ + "文件.txt": "hello world", + }, + }, + { + name: "FileNamePathTraversal", + data: url.Values{ + "title": {"Path Traversal"}, + "name": {"../../../etc/passwd"}, + "content": {"malicious"}, + }, + expectedCode: 302, + expectGistCreated: true, + expectedTitle: "Path Traversal", + expectedNbFiles: 1, + expectedVisibility: db.PublicVisibility, + expectedFileNames: []string{"passwd"}, + expectedFileContents: map[string]string{ + "passwd": "malicious", + }, + }, + { + name: "EmptyAndValidContent", + data: url.Values{ + "title": {"Mixed Content"}, + "name": []string{"empty.txt", "valid.txt", "also-empty.txt"}, + "content": []string{"", "valid content", ""}, + }, + expectedCode: 302, + expectGistCreated: true, + expectedTitle: "Mixed Content", + expectedNbFiles: 1, + expectedVisibility: db.PublicVisibility, + expectedFileNames: []string{"valid.txt"}, + expectedFileContents: map[string]string{ + "valid.txt": "valid content", + }, + }, + { + name: "ContentWithSpecialCharacters", + data: url.Values{ + "title": {"Special Chars"}, + "name": {"special.txt"}, + "content": {"Line1\nLine2\tTabbed\x00NullByte😀Emoji"}, + }, + expectedCode: 302, + expectGistCreated: true, + expectedTitle: "Special Chars", + expectedNbFiles: 1, + expectedVisibility: db.PublicVisibility, + expectedFileNames: []string{"special.txt"}, + expectedFileContents: map[string]string{ + "special.txt": "Line1\nLine2\tTabbed\x00NullByte😀Emoji", + }, + }, + { + name: "ContentMultibyteUnicode", + data: url.Values{ + "title": {"Unicode Content"}, + "name": {"unicode.txt"}, + "content": {"Hello 世界 🌍 Привет"}, + }, + expectedCode: 302, + expectGistCreated: true, + expectedTitle: "Unicode Content", + expectedNbFiles: 1, + expectedVisibility: db.PublicVisibility, + expectedFileNames: []string{"unicode.txt"}, + expectedFileContents: map[string]string{ + "unicode.txt": "Hello 世界 🌍 Привет", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s.Login(t, "thomas") + + resp := s.Request(t, "POST", "/", tt.data, tt.expectedCode) + + if tt.expectGistCreated { + // Get gist info from redirect + username, gistIdentifier := getGistInfoFromRedirect(resp) + require.Equal(t, "thomas", username) + require.NotEmpty(t, gistIdentifier) + + // Verify gist was created + gist, err := db.GetGist(username, gistIdentifier) + require.NoError(t, err) + + // Run common verification (filesystem, git, etc.) + verifyGistCreation(t, gist, username, gistIdentifier) + + // Verify all expected fields + require.Equal(t, tt.expectedTitle, gist.Title, "Title mismatch") + require.Equal(t, tt.expectedNbFiles, gist.NbFiles, "File count mismatch") + require.Equal(t, tt.expectedVisibility, gist.Private, "Visibility mismatch") + + // Verify description if specified + if tt.expectedDescription != "" { + require.Equal(t, tt.expectedDescription, gist.Description, "Description mismatch") + } + + // Verify URL if specified + if tt.expectedURL != "" { + require.Equal(t, tt.expectedURL, gist.Identifier(), "URL/Identifier mismatch") + } + + // Verify topics if specified + if tt.expectedTopics != "" { + // Get gist topics + topics, err := gist.GetTopics() + require.NoError(t, err, "Failed to get gist topics") + require.ElementsMatch(t, strings.Split(tt.expectedTopics, ","), topics, "Topics mismatch") + } + + // Verify files if specified + if len(tt.expectedFileNames) > 0 { + files, err := gist.Files("HEAD", false) + require.NoError(t, err, "Failed to get gist files") + require.Len(t, files, len(tt.expectedFileNames), "File count mismatch") + + actualFileNames := make([]string, len(files)) + for i, file := range files { + actualFileNames[i] = file.Filename + } + require.ElementsMatch(t, tt.expectedFileNames, actualFileNames, "File names mismatch") + + // Verify file contents if specified + if len(tt.expectedFileContents) > 0 { + for filename, expectedContent := range tt.expectedFileContents { + content, _, err := git.GetFileContent(username, gist.Uuid, "HEAD", filename, false) + require.NoError(t, err, "Failed to get content for file %s", filename) + require.Equal(t, expectedContent, content, "Content mismatch for file %s", filename) + } + } + } + } + }) + } +} diff --git a/internal/web/handlers/gist/delete_test.go b/internal/web/handlers/gist/delete_test.go new file mode 100644 index 0000000..f20b2c9 --- /dev/null +++ b/internal/web/handlers/gist/delete_test.go @@ -0,0 +1,74 @@ +package gist_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/db" + webtest "github.com/thomiceli/opengist/internal/web/test" +) + +func TestDeleteGist(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + s.Register(t, "alice") + + t.Run("NoAuth", func(t *testing.T) { + gistPath, _, username, identifier := s.CreateGist(t, "0") + + deleteURL := "/" + username + "/" + identifier + "/delete" + s.Request(t, "POST", deleteURL, nil, 302) + + gistCheck, err := db.GetGist(username, identifier) + require.NoError(t, err, "Gist should still exist in database") + require.NotNil(t, gistCheck) + + _, err = os.Stat(gistPath) + require.NoError(t, err, "Gist should still exist on filesystem") + }) + + t.Run("DeleteOwnGist", func(t *testing.T) { + gistPath, _, username, identifier := s.CreateGist(t, "0") + + gistCheck, err := db.GetGist(username, identifier) + require.NoError(t, err) + require.NotNil(t, gistCheck) + + s.Login(t, "thomas") + deleteURL := "/" + username + "/" + identifier + "/delete" + s.Request(t, "POST", deleteURL, nil, 302) + + gistCheck, err = db.GetGist(username, identifier) + require.Error(t, err, "Gist should be deleted from database") + + _, err = os.Stat(gistPath) + require.Error(t, err, "Gist should not exist on filesystem after deletion") + require.True(t, os.IsNotExist(err), "Filesystem should return 'not exist' error") + require.Equal(t, uint(0), gistCheck.ID, "Gist should be not in database after deletion") + }) + + t.Run("DeleteOthersGist", func(t *testing.T) { + gistPath, _, username, identifier := s.CreateGist(t, "0") + + s.Login(t, "alice") + deleteURL := "/" + username + "/" + identifier + "/delete" + s.Request(t, "POST", deleteURL, nil, 403) + + gistCheck, err := db.GetGist(username, identifier) + require.NoError(t, err, "Gist should still exist in database") + require.NotNil(t, gistCheck) + + _, err = os.Stat(gistPath) + require.NoError(t, err, "Gist should still exist on filesystem") + }) + + t.Run("DeleteNonExistentGist", func(t *testing.T) { + s.Login(t, "thomas") + + deleteURL := "/thomas/nonexistent-gist-12345/delete" + s.Request(t, "POST", deleteURL, nil, 404) + }) +} diff --git a/internal/web/handlers/gist/download_test.go b/internal/web/handlers/gist/download_test.go new file mode 100644 index 0000000..597d09e --- /dev/null +++ b/internal/web/handlers/gist/download_test.go @@ -0,0 +1,141 @@ +package gist_test + +import ( + "archive/zip" + "bytes" + "io" + "testing" + + "github.com/stretchr/testify/require" + webtest "github.com/thomiceli/opengist/internal/web/test" +) + +func TestDownloadZip(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + t.Run("MultipleFiles", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + resp := s.Request(t, "GET", "/"+username+"/"+identifier+"/archive/HEAD", nil, 200) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + zipReader, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + require.NoError(t, err) + require.Len(t, zipReader.File, 2) + + fileNames := make([]string, len(zipReader.File)) + contents := make([]string, len(zipReader.File)) + for i, file := range zipReader.File { + fileNames[i] = file.Name + f, err := file.Open() + require.NoError(t, err) + content, err := io.ReadAll(f) + require.NoError(t, err) + contents[i] = string(content) + f.Close() + } + require.ElementsMatch(t, []string{"file.txt", "otherfile.txt"}, fileNames) + require.ElementsMatch(t, []string{"hello world", "other content"}, contents) + }) + + t.Run("PrivateGist", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "2") + + s.Request(t, "GET", "/"+username+"/"+identifier+"/archive/HEAD", nil, 404) + }) + + t.Run("NonExistentRevision", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + // TODO: return 404 + s.Request(t, "GET", "/"+username+"/"+identifier+"/archive/zz", nil, 0) + }) +} + +func TestRawFile(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + t.Run("ExistingFile", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + resp := s.Request(t, "GET", "/"+username+"/"+identifier+"/raw/HEAD/file.txt", nil, 200) + + require.Equal(t, `inline; filename="file.txt"`, resp.Header.Get("Content-Disposition")) + require.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options")) + require.Contains(t, resp.Header.Get("Content-Type"), "text/plain") + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "hello world", string(body)) + }) + + t.Run("NonExistentFile", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + s.Request(t, "GET", "/"+username+"/"+identifier+"/raw/HEAD/nonexistent.txt", nil, 404) + }) + + t.Run("NonExistentRevision", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + s.Request(t, "GET", "/"+username+"/"+identifier+"/raw/zz/file.txt", nil, 404) + }) + + t.Run("PrivateGist", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "2") + + s.Request(t, "GET", "/"+username+"/"+identifier+"/raw/HEAD/file.txt", nil, 404) + }) +} + +func TestDownloadFile(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + t.Run("ExistingFile", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + resp := s.Request(t, "GET", "/"+username+"/"+identifier+"/download/HEAD/file.txt", nil, 200) + + require.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type")) + require.Equal(t, `attachment; filename="file.txt"`, resp.Header.Get("Content-Disposition")) + require.Equal(t, "11", resp.Header.Get("Content-Length")) + require.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options")) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "hello world", string(body)) + }) + + t.Run("NonExistentFile", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + resp := s.Request(t, "GET", "/"+username+"/"+identifier+"/download/HEAD/nonexistent.txt", nil, 404) + + _, err := io.ReadAll(resp.Body) + require.NoError(t, err) + // TODO: change the response to not found + // require.Equal(t, "File not found", string(body)) + }) + + t.Run("NonExistentRevision", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + resp := s.Request(t, "GET", "/"+username+"/"+identifier+"/download/zz/file.txt", nil, 404) + + _, err := io.ReadAll(resp.Body) + require.NoError(t, err) + // TODO: change the response to not found + // require.Equal(t, "File not found", string(body)) + }) + + t.Run("PrivateGist", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "2") + + s.Request(t, "GET", "/"+username+"/"+identifier+"/download/HEAD/file.txt", nil, 404) + }) +} diff --git a/internal/web/handlers/gist/edit.go b/internal/web/handlers/gist/edit.go index e914d84..b95aa3d 100644 --- a/internal/web/handlers/gist/edit.go +++ b/internal/web/handlers/gist/edit.go @@ -1,10 +1,11 @@ package gist import ( + "strconv" + "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/render" "github.com/thomiceli/opengist/internal/web/context" - "strconv" ) func Edit(ctx *context.Context) error { diff --git a/internal/web/handlers/gist/edit_test.go b/internal/web/handlers/gist/edit_test.go new file mode 100644 index 0000000..defdab2 --- /dev/null +++ b/internal/web/handlers/gist/edit_test.go @@ -0,0 +1,66 @@ +package gist_test + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/db" + webtest "github.com/thomiceli/opengist/internal/web/test" +) + +func TestVisibility(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + s.Register(t, "alice") + + t.Run("ChangeVisibility", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + s.Login(t, "thomas") + s.Request(t, "POST", "/"+username+"/"+identifier+"/visibility", url.Values{ + "private": {"2"}, + }, 302) + + gist, err := db.GetGist(username, identifier) + require.NoError(t, err) + require.Equal(t, db.PrivateVisibility, gist.Private) + }) + + t.Run("ChangeToUnlisted", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + s.Login(t, "thomas") + s.Request(t, "POST", "/"+username+"/"+identifier+"/visibility", url.Values{ + "private": {"1"}, + }, 302) + + gist, err := db.GetGist(username, identifier) + require.NoError(t, err) + require.Equal(t, db.UnlistedVisibility, gist.Private) + }) + + t.Run("OtherUserCannotChange", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + s.Login(t, "alice") + s.Request(t, "POST", "/"+username+"/"+identifier+"/visibility", nil, 403) + + gist, err := db.GetGist(username, identifier) + require.NoError(t, err) + require.Equal(t, db.PublicVisibility, gist.Private) + }) + + t.Run("NoAuth", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + s.Logout() + s.Request(t, "POST", "/"+username+"/"+identifier+"/visibility", nil, 302) + + gist, err := db.GetGist(username, identifier) + require.NoError(t, err) + require.Equal(t, db.PublicVisibility, gist.Private) + }) +} diff --git a/internal/web/handlers/gist/fork_test.go b/internal/web/handlers/gist/fork_test.go new file mode 100644 index 0000000..503eafb --- /dev/null +++ b/internal/web/handlers/gist/fork_test.go @@ -0,0 +1,102 @@ +package gist_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/db" + webtest "github.com/thomiceli/opengist/internal/web/test" +) + +func TestFork(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + s.Register(t, "alice") + + t.Run("Fork", func(t *testing.T) { + _, gist, username, identifier := s.CreateGist(t, "0") + s.Login(t, "alice") + + resp := s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 302) + + forkedGist, err := db.GetGistByID("2") + require.NoError(t, err) + require.Equal(t, "alice", forkedGist.User.Username) + require.Equal(t, gist.Title, forkedGist.Title) + require.Equal(t, gist.Description, forkedGist.Description) + require.Equal(t, gist.Private, forkedGist.Private) + require.Equal(t, gist.ID, forkedGist.ForkedID) + + forkedFiles, err := forkedGist.Files("HEAD", false) + require.NoError(t, err) + + gistFiles, err := gist.Files("HEAD", false) + require.NoError(t, err) + + for i, file := range gistFiles { + require.Equal(t, file.Filename, forkedFiles[i].Filename) + require.Equal(t, file.Content, forkedFiles[i].Content) + } + + require.Equal(t, "/alice/"+forkedGist.Identifier(), resp.Header.Get("Location")) + + original, err := db.GetGistByID("1") + require.NoError(t, err) + require.Equal(t, 1, original.NbForks) + + forks, err := original.GetForks(2, 0) + require.NoError(t, err) + require.Len(t, forks, 1) + require.Equal(t, forkedGist.ID, forks[0].ID) + + forkedGists, err := db.GetAllGistsForkedByUser(2, 2, 0, "created", "asc") + require.NoError(t, err) + require.Len(t, forkedGists, 1) + require.Equal(t, forkedGist.ID, forkedGists[0].ID) + }) + + t.Run("OwnGist", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + s.Login(t, "thomas") + + s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 302) + + original, err := db.GetGist(username, identifier) + require.NoError(t, err) + require.Equal(t, 0, original.NbForks) + }) + + t.Run("AlreadyForked", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + s.Login(t, "alice") + + firstResp := s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 302) + forkLocation := firstResp.Header.Get("Location") + + secondResp := s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 302) + require.Equal(t, forkLocation, secondResp.Header.Get("Location")) + + original, err := db.GetGist(username, identifier) + require.NoError(t, err) + require.Equal(t, 1, original.NbForks) + }) + + t.Run("NoAuth", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 302) + + original, err := db.GetGist(username, identifier) + require.NoError(t, err) + require.Equal(t, 0, original.NbForks) + }) + + t.Run("PrivateGist", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "2") + s.Login(t, "alice") + + s.Request(t, "POST", "/"+username+"/"+identifier+"/fork", nil, 404) + }) +} diff --git a/internal/web/handlers/gist/gist_test.go b/internal/web/handlers/gist/gist_test.go new file mode 100644 index 0000000..b66ac22 --- /dev/null +++ b/internal/web/handlers/gist/gist_test.go @@ -0,0 +1,293 @@ +package gist_test + +import ( + "encoding/json" + "io" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/db" + "github.com/thomiceli/opengist/internal/web/context" + webtest "github.com/thomiceli/opengist/internal/web/test" +) + +func setupManifestEntries() { + context.ManifestEntries = map[string]context.Asset{ + "embed.css": {File: "assets/embed.css"}, + "ts/embed.ts": {Css: []string{"assets/embed.css"}}, + "ts/light.ts": {Css: []string{"assets/light.css"}}, + "ts/dark.ts": {Css: []string{"assets/dark.css"}}, + } +} + +func TestGistIndex(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + s.Register(t, "alice") + + t.Run("Public", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + s.Request(t, "GET", "/"+username+"/"+identifier, nil, 200) + }) + + t.Run("NonExistentRevision", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + s.Request(t, "GET", "/"+username+"/"+identifier+"/rev/nonexistent", nil, 404) + }) + + t.Run("NonExistentGist", func(t *testing.T) { + s.Request(t, "GET", "/thomas/nonexistent", nil, 404) + }) + + t.Run("Unlisted", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "1") + + s.Login(t, "thomas") + s.Request(t, "GET", "/"+username+"/"+identifier, nil, 200) + + s.Login(t, "alice") + s.Request(t, "GET", "/"+username+"/"+identifier, nil, 200) + + s.Logout() + s.Request(t, "GET", "/"+username+"/"+identifier, nil, 200) + }) + + t.Run("Private", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "2") + + s.Login(t, "thomas") + s.Request(t, "GET", "/"+username+"/"+identifier, nil, 200) + + s.Login(t, "alice") + s.Request(t, "GET", "/"+username+"/"+identifier, nil, 404) + + s.Logout() + s.Request(t, "GET", "/"+username+"/"+identifier, nil, 404) + }) + + t.Run("SpecificRevision", func(t *testing.T) { + _, gist, username, identifier := s.CreateGist(t, "0") + + s.Login(t, "thomas") + s.Request(t, "POST", "/"+username+"/"+identifier+"/edit", url.Values{ + "title": {"Test"}, + "name": {"file.txt"}, + "content": {"updated content"}, + }, 302) + + files, err := gist.Files("HEAD", false) + require.NoError(t, err) + found := false + for _, f := range files { + if f.Filename == "file.txt" { + require.Equal(t, "updated content", f.Content) + found = true + } + } + require.True(t, found) + + commits, err := gist.Log(0) + require.NoError(t, err) + require.Len(t, commits, 2) + + filesOld, err := gist.Files(commits[1].Hash, false) + require.NoError(t, err) + for _, f := range filesOld { + if f.Filename == "file.txt" { + require.Equal(t, "hello world", f.Content) + } + } + + s.Request(t, "GET", "/"+username+"/"+identifier+"/rev/HEAD", nil, 200) + s.Request(t, "GET", "/"+username+"/"+identifier+"/rev/"+commits[1].Hash, nil, 200) + }) +} + +func TestPreview(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + + t.Run("Markdown", func(t *testing.T) { + s.Login(t, "thomas") + + resp := s.Request(t, "POST", "/preview", url.Values{ + "content": {"# Hello\n\nThis is **bold** and *italic*."}, + }, 200) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + html := string(body) + require.Contains(t, html, "

") + require.Contains(t, html, "Hello") + require.Contains(t, html, "bold") + require.Contains(t, html, "italic") + }) + + t.Run("NoAuth", func(t *testing.T) { + s.Logout() + s.Request(t, "POST", "/preview", url.Values{ + "content": {"# Hello"}, + }, 302) + }) +} + +func TestGistJson(t *testing.T) { + setupManifestEntries() + + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + s.Register(t, "alice") + + t.Run("Public", func(t *testing.T) { + _, gist, username, identifier := s.CreateGist(t, "0") + + resp := s.Request(t, "GET", "/"+username+"/"+identifier+".json", nil, 200) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(body, &result) + require.NoError(t, err) + t.Helper() + + require.Equal(t, username, result["owner"]) + require.Equal(t, identifier, result["id"]) + require.Equal(t, gist.Uuid, result["uuid"]) + require.Equal(t, gist.Title, result["title"]) + require.Equal(t, "public", result["visibility"]) + require.Equal(t, []interface{}{"hello", "opengist"}, result["topics"]) + require.Equal(t, []interface{}{ + map[string]interface{}{ + "content": "hello world", + "filename": "file.txt", + "human_size": "11 B", + "size": float64(11), + "truncated": false, + "type": "Text", + }, + map[string]interface{}{ + "content": "other content", + "filename": "otherfile.txt", + "human_size": "13 B", + "size": float64(13), + "truncated": false, + "type": "Text", + }, + }, result["files"]) + + embed, ok := result["embed"].(map[string]interface{}) + require.True(t, ok) + require.Contains(t, embed["js"], identifier+".js") + require.Contains(t, embed["js_dark"], identifier+".js?dark") + require.NotEmpty(t, embed["css"]) + require.NotEmpty(t, embed["html"]) + }) + + t.Run("Unlisted", func(t *testing.T) { + s.Logout() + _, _, username, identifier := s.CreateGist(t, "1") + + s.Request(t, "GET", "/"+username+"/"+identifier+".json", nil, 200) + }) + + t.Run("Private", func(t *testing.T) { + s.Logout() + _, _, username, identifier := s.CreateGist(t, "2") + + s.Request(t, "GET", "/"+username+"/"+identifier+".json", nil, 404) + }) + + t.Run("NonExistentGist", func(t *testing.T) { + s.Request(t, "GET", "/thomas/nonexistent.json", nil, 404) + }) +} + +func TestGistAccess(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + s.Register(t, "alice") + + _, _, user, publicId := s.CreateGist(t, "0") + _, _, _, unlistedId := s.CreateGist(t, "1") + _, _, _, privateId := s.CreateGist(t, "2") + + tests := []struct { + name string + settings map[string]string + // expected codes: [owner, otherUser, anonymous] x [public, unlisted, private] + owner, otherUser, anonymous []int + }{ + { + name: "Default", + owner: []int{200, 200, 200}, + otherUser: []int{200, 200, 404}, + anonymous: []int{200, 200, 404}, + }, + { + name: "RequireLogin", + settings: map[string]string{db.SettingRequireLogin: "1"}, + owner: []int{200, 200, 200}, + otherUser: []int{200, 200, 404}, + anonymous: []int{302, 302, 302}, + }, + { + name: "AllowGistsWithoutLogin", + settings: map[string]string{db.SettingRequireLogin: "1", db.SettingAllowGistsWithoutLogin: "1"}, + owner: []int{200, 200, 200}, + otherUser: []int{200, 200, 404}, + anonymous: []int{200, 200, 404}, + }, + } + + gists := []string{publicId, unlistedId, privateId} + labels := []string{"Public", "Unlisted", "Private"} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s.Login(t, "thomas") + for k, v := range tt.settings { + s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200) + } + + t.Run("Owner", func(t *testing.T) { + s.Login(t, "thomas") + for i, id := range gists { + s.Request(t, "GET", "/"+user+"/"+id, nil, tt.owner[i]) + } + }) + + t.Run("OtherUser", func(t *testing.T) { + s.Login(t, "alice") + for i, id := range gists { + s.Request(t, "GET", "/"+user+"/"+id, nil, tt.otherUser[i]) + } + }) + + t.Run("Anonymous", func(t *testing.T) { + s.Logout() + for i, id := range gists { + t.Run(labels[i], func(t *testing.T) { + s.Request(t, "GET", "/"+user+"/"+id, nil, tt.anonymous[i]) + }) + } + }) + + s.Login(t, "thomas") + for k := range tt.settings { + s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200) + } + }) + } +} diff --git a/internal/web/handlers/gist/like_test.go b/internal/web/handlers/gist/like_test.go new file mode 100644 index 0000000..575008f --- /dev/null +++ b/internal/web/handlers/gist/like_test.go @@ -0,0 +1,96 @@ +package gist_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/db" + webtest "github.com/thomiceli/opengist/internal/web/test" +) + +func TestLike(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + s.Register(t, "alice") + + t.Run("Like", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + s.Login(t, "alice") + resp := s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302) + require.Equal(t, "/"+username+"/"+identifier, resp.Header.Get("Location")) + + s.Login(t, "thomas") + s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302) + + gist, err := db.GetGist(username, identifier) + require.NoError(t, err) + require.Equal(t, 2, gist.NbLikes) + + likers, err := gist.GetUsersLikes(0) + require.NoError(t, err) + require.Len(t, likers, 2) + }) + + t.Run("Unlike", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + s.Login(t, "alice") + s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302) + + s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302) + + gist, err := db.GetGist(username, identifier) + require.NoError(t, err) + require.Equal(t, 0, gist.NbLikes) + + likers, err := gist.GetUsersLikes(0) + require.NoError(t, err) + require.Len(t, likers, 0) + }) + + t.Run("NoAuth", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + s.Logout() + s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302) + + gist, err := db.GetGist(username, identifier) + require.NoError(t, err) + require.Equal(t, 0, gist.NbLikes) + }) + + t.Run("PrivateGist", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "2") + + s.Login(t, "alice") + s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 404) + }) +} + +func TestLikes(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + s.Register(t, "alice") + + t.Run("Likes", func(t *testing.T) { + _, gist, username, identifier := s.CreateGist(t, "0") + + s.Login(t, "thomas") + s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302) + s.Login(t, "alice") + s.Request(t, "POST", "/"+username+"/"+identifier+"/like", nil, 302) + + s.Request(t, "GET", "/"+username+"/"+identifier+"/likes", nil, 200) + + users, err := gist.GetUsersLikes(0) + require.NoError(t, err) + require.Len(t, users, 2) + require.Equal(t, "thomas", users[0].Username) + require.Equal(t, "alice", users[1].Username) + }) +} diff --git a/internal/web/handlers/gist/revisions.go b/internal/web/handlers/gist/revisions.go index b98bdc0..79ebd14 100644 --- a/internal/web/handlers/gist/revisions.go +++ b/internal/web/handlers/gist/revisions.go @@ -1,10 +1,11 @@ package gist import ( + "strings" + "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/handlers" - "strings" ) func Revisions(ctx *context.Context) error { diff --git a/internal/web/handlers/gist/revisions_test.go b/internal/web/handlers/gist/revisions_test.go new file mode 100644 index 0000000..ea77c17 --- /dev/null +++ b/internal/web/handlers/gist/revisions_test.go @@ -0,0 +1,153 @@ +package gist_test + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/git" + webtest "github.com/thomiceli/opengist/internal/web/test" +) + +func TestRevisions(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + s.Register(t, "alice") + + t.Run("Revisions", func(t *testing.T) { + _, gist, username, identifier := s.CreateGist(t, "0") + + s.Login(t, "thomas") + s.Request(t, "POST", "/"+username+"/"+identifier+"/edit", url.Values{ + "title": {"Test"}, + "name": {"file.txt", "ok.txt"}, + "content": {"updated content", "okay"}, + }, 302) + + s.Request(t, "POST", "/"+username+"/"+identifier+"/edit", url.Values{ + "title": {"Test"}, + "name": {"renamed.txt", "ok.txt"}, + "content": {"updated content", "okay"}, + }, 302) + + commits, err := gist.Log(0) + require.NoError(t, err) + + require.Len(t, commits, 3) + + require.Regexp(t, "^[a-f0-9]{40}$", commits[0].Hash) + require.Regexp(t, "^[a-f0-9]{40}$", commits[1].Hash) + require.Regexp(t, "^[a-f0-9]{40}$", commits[2].Hash) + + require.Equal(t, &git.Commit{ + Hash: commits[0].Hash, + Timestamp: commits[0].Timestamp, + AuthorName: "thomas", + Changed: "1 file changed, 0 insertions, 0 deletions", + Files: []git.File{ + { + Filename: "renamed.txt", + Size: 0, + HumanSize: "", + OldFilename: "file.txt", + Content: ``, + Truncated: false, + IsCreated: false, + IsDeleted: false, + IsBinary: false, + MimeType: git.MimeType{}, + }, + }, + }, commits[0]) + + require.Equal(t, &git.Commit{ + Hash: commits[1].Hash, + Timestamp: commits[1].Timestamp, + AuthorName: "thomas", + Changed: "3 files changed, 2 insertions, 2 deletions", + Files: []git.File{ + { + Filename: "file.txt", + OldFilename: "file.txt", + Content: `@@ -1 +1 @@ +-hello world +\ No newline at end of file ++updated content +\ No newline at end of file +`, + IsCreated: false, + IsDeleted: false, + IsBinary: false, + }, { + Filename: "ok.txt", + OldFilename: "", + Content: `@@ -0,0 +1 @@ ++okay +\ No newline at end of file +`, + IsCreated: true, + IsDeleted: false, + IsBinary: false, + }, { + Filename: "otherfile.txt", + OldFilename: "", + Content: `@@ -1 +0,0 @@ +-other content +\ No newline at end of file +`, + IsCreated: false, + IsDeleted: true, + IsBinary: false, + }, + }, + }, commits[1]) + + require.Equal(t, &git.Commit{ + Hash: commits[2].Hash, + Timestamp: commits[2].Timestamp, + AuthorName: "thomas", + Changed: "2 files changed, 2 insertions", + Files: []git.File{ + { + Filename: "file.txt", + OldFilename: "", + Content: `@@ -0,0 +1 @@ ++hello world +\ No newline at end of file +`, + IsCreated: true, + IsDeleted: false, + IsBinary: false, + }, { + Filename: "otherfile.txt", + OldFilename: "", + Content: `@@ -0,0 +1 @@ ++other content +\ No newline at end of file +`, + IsCreated: true, + IsDeleted: false, + IsBinary: false, + }, + }, + }, commits[2]) + + s.Request(t, "GET", "/"+username+"/"+identifier+"/revisions", nil, 200) + }) + + t.Run("NoAuth", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "0") + + s.Logout() + s.Request(t, "GET", "/"+username+"/"+identifier+"/revisions", nil, 200) + }) + + t.Run("PrivateGist", func(t *testing.T) { + _, _, username, identifier := s.CreateGist(t, "2") + + s.Login(t, "alice") + s.Request(t, "GET", "/"+username+"/"+identifier+"/revisions", nil, 404) + }) +} diff --git a/internal/web/handlers/gist/upload_test.go b/internal/web/handlers/gist/upload_test.go new file mode 100644 index 0000000..d1d3397 --- /dev/null +++ b/internal/web/handlers/gist/upload_test.go @@ -0,0 +1,151 @@ +package gist_test + +import ( + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/config" + webtest "github.com/thomiceli/opengist/internal/web/test" +) + +func createMultipartRequest(t *testing.T, uri, fieldName, fileName, content string) *http.Request { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile(fieldName, fileName) + require.NoError(t, err) + _, err = part.Write([]byte(content)) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + req := httptest.NewRequest(http.MethodPost, uri, body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + return req +} + +func TestUpload(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + + t.Run("UploadFile", func(t *testing.T) { + s.Login(t, "thomas") + + req := createMultipartRequest(t, "/upload", "file", "test.txt", "file content") + + resp := s.RawRequest(t, req, 200) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var result map[string]string + err = json.Unmarshal(body, &result) + require.NoError(t, err) + + require.Equal(t, "test.txt", result["filename"]) + require.NotEmpty(t, result["uuid"]) + require.Regexp(t, `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`, result["uuid"]) + + filePath := filepath.Join(config.GetHomeDir(), "uploads", result["uuid"]) + data, err := os.ReadFile(filePath) + require.NoError(t, err) + require.Equal(t, "file content", string(data)) + }) + + t.Run("NoFile", func(t *testing.T) { + s.Login(t, "thomas") + + req := httptest.NewRequest(http.MethodPost, "/upload", nil) + req.Header.Set("Content-Type", "multipart/form-data; boundary=xxx") + + s.RawRequest(t, req, 400) + }) + + t.Run("NoAuth", func(t *testing.T) { + s.Logout() + + req := createMultipartRequest(t, "/upload", "file", "test.txt", "content") + + s.RawRequest(t, req, 302) + }) +} + +func TestDeleteUpload(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + + t.Run("DeleteExistingFile", func(t *testing.T) { + s.Login(t, "thomas") + + req := createMultipartRequest(t, "/upload", "file", "todelete.txt", "delete me") + + uploadResp := s.RawRequest(t, req, 200) + + body, err := io.ReadAll(uploadResp.Body) + require.NoError(t, err) + var uploadResult map[string]string + err = json.Unmarshal(body, &uploadResult) + require.NoError(t, err) + fileUUID := uploadResult["uuid"] + + filePath := filepath.Join(config.GetHomeDir(), "uploads", fileUUID) + _, err = os.Stat(filePath) + require.NoError(t, err) + + deleteReq := httptest.NewRequest(http.MethodDelete, "/upload/"+fileUUID, nil) + + deleteResp := s.RawRequest(t, deleteReq, 200) + + deleteBody, err := io.ReadAll(deleteResp.Body) + require.NoError(t, err) + var deleteResult map[string]string + err = json.Unmarshal(deleteBody, &deleteResult) + require.NoError(t, err) + require.Equal(t, "deleted", deleteResult["status"]) + + _, err = os.Stat(filePath) + require.True(t, os.IsNotExist(err)) + }) + + t.Run("DeleteNonExistentFile", func(t *testing.T) { + s.Login(t, "thomas") + + req := httptest.NewRequest(http.MethodDelete, "/upload/00000000-0000-0000-0000-000000000000", nil) + + s.RawRequest(t, req, 200) + }) + + t.Run("InvalidUUID", func(t *testing.T) { + s.Login(t, "thomas") + + req := httptest.NewRequest(http.MethodDelete, "/upload/not-a-valid-uuid", nil) + + s.RawRequest(t, req, 400) + }) + + t.Run("PathTraversal", func(t *testing.T) { + s.Login(t, "thomas") + + req := httptest.NewRequest(http.MethodDelete, "/upload/../../etc/passwd", nil) + + s.RawRequest(t, req, 400) + }) + + t.Run("NoAuth", func(t *testing.T) { + s.Logout() + + req := httptest.NewRequest(http.MethodDelete, "/upload/00000000-0000-0000-0000-000000000000", nil) + + s.RawRequest(t, req, 302) + }) +} diff --git a/internal/web/handlers/git/http_test.go b/internal/web/handlers/git/http_test.go new file mode 100644 index 0000000..b822ed8 --- /dev/null +++ b/internal/web/handlers/git/http_test.go @@ -0,0 +1,235 @@ +package git_test + +import ( + "net/url" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/db" + webtest "github.com/thomiceli/opengist/internal/web/test" +) + +func gitClone(baseUrl, creds, user, gistId, destDir string) error { + authUrl := baseUrl + if creds != "" { + authUrl = "http://" + creds + "@" + baseUrl[len("http://"):] + } + return exec.Command("git", "clone", authUrl+"/"+user+"/"+gistId+".git", destDir).Run() +} + +func gitPush(repoDir, filename, content string) error { + if err := os.WriteFile(filepath.Join(repoDir, filename), []byte(content), 0644); err != nil { + return err + } + if err := exec.Command("git", "-C", repoDir, "add", filename).Run(); err != nil { + return err + } + if err := exec.Command("git", "-C", repoDir, "commit", "-m", "add "+filename).Run(); err != nil { + return err + } + return exec.Command("git", "-C", repoDir, "push", "origin").Run() +} + +func TestGitClonePull(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + baseUrl := s.StartHttpServer(t) + + s.Register(t, "thomas") + s.Register(t, "alice") + + _, _, user, publicId := s.CreateGist(t, "0") + _, _, _, unlistedId := s.CreateGist(t, "1") + _, _, _, privateId := s.CreateGist(t, "2") + + type credTest struct { + name string + creds string + expect [3]bool // [public, unlisted, private] + } + + tests := []struct { + name string + settings map[string]string + creds []credTest + }{ + { + name: "Default", + creds: []credTest{ + {"OwnerAuth", "thomas:thomas", [3]bool{true, true, true}}, + {"OtherUserAuth", "alice:alice", [3]bool{true, true, false}}, + {"WrongPassword", "thomas:wrong", [3]bool{true, true, false}}, + {"WrongUser", "aze:aze", [3]bool{true, true, false}}, + {"Anonymous", "", [3]bool{true, true, false}}, + }, + }, + { + name: "RequireLogin", + settings: map[string]string{db.SettingRequireLogin: "1"}, + creds: []credTest{ + {"OwnerAuth", "thomas:thomas", [3]bool{true, true, true}}, + {"OtherUserAuth", "alice:alice", [3]bool{true, true, false}}, + {"WrongPassword", "thomas:wrong", [3]bool{false, false, false}}, + {"WrongUser", "aze:aze", [3]bool{false, false, false}}, + {"Anonymous", "", [3]bool{false, false, false}}, + }, + }, + { + name: "AllowGistsWithoutLogin", + settings: map[string]string{db.SettingRequireLogin: "1", db.SettingAllowGistsWithoutLogin: "1"}, + creds: []credTest{ + {"OwnerAuth", "thomas:thomas", [3]bool{true, true, true}}, + {"OtherUserAuth", "alice:alice", [3]bool{true, true, false}}, + {"WrongPassword", "thomas:wrong", [3]bool{true, true, false}}, + {"WrongUser", "aze:aze", [3]bool{true, true, false}}, + {"Anonymous", "", [3]bool{true, true, false}}, + }, + }, + } + + gists := [3]string{publicId, unlistedId, privateId} + labels := [3]string{"Public", "Unlisted", "Private"} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s.Login(t, "thomas") + for k, v := range tt.settings { + s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {v}}, 200) + } + + for _, ct := range tt.creds { + t.Run(ct.name, func(t *testing.T) { + for i, id := range gists { + t.Run(labels[i], func(t *testing.T) { + dest := t.TempDir() + err := gitClone(baseUrl, ct.creds, user, id, dest) + if ct.expect[i] { + require.NoError(t, err) + _, err = os.Stat(filepath.Join(dest, "file.txt")) + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } + }) + } + + // Reset settings + s.Login(t, "thomas") + for k := range tt.settings { + s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {k}, "value": {"0"}}, 200) + } + }) + } +} + +func TestGitPush(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + baseUrl := s.StartHttpServer(t) + + s.Register(t, "thomas") + s.Register(t, "alice") + + _, _, user, publicId := s.CreateGist(t, "0") + _, _, _, unlistedId := s.CreateGist(t, "1") + _, _, _, privateId := s.CreateGist(t, "2") + + type credTest struct { + name string + creds string + expect [3]bool // [public, unlisted, private] + } + + tests := []credTest{ + {"OwnerAuth", "thomas:thomas", [3]bool{true, true, true}}, + {"OtherUserAuth", "alice:alice", [3]bool{false, false, false}}, + {"WrongPassword", "thomas:wrong", [3]bool{false, false, false}}, + {"WrongUser", "aze:aze", [3]bool{false, false, false}}, + {"Anonymous", "", [3]bool{false, false, false}}, + } + + gists := [3]string{publicId, unlistedId, privateId} + labels := [3]string{"Public", "Unlisted", "Private"} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for i, id := range gists { + t.Run(labels[i], func(t *testing.T) { + dest := t.TempDir() + require.NoError(t, gitClone(baseUrl, "thomas:thomas", user, id, dest)) + + if tt.creds != "thomas:thomas" { + require.NoError(t, exec.Command("git", "-C", dest, "remote", "set-url", "origin", + "http://"+tt.creds+"@"+baseUrl[len("http://"):]+"/"+user+"/"+id+".git").Run()) + } + + err := gitPush(dest, "newfile.txt", "new content") + if tt.expect[i] { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } + }) + } +} + +func TestGitCreatePush(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + baseUrl := s.StartHttpServer(t) + + s.Register(t, "thomas") + s.Register(t, "alice") + + gitInitAndPush := func(t *testing.T, creds, remoteUrl string) error { + dest := t.TempDir() + require.NoError(t, exec.Command("git", "init", "--initial-branch=master", dest).Run()) + require.NoError(t, exec.Command("git", "-C", dest, "remote", "add", "origin", + "http://"+creds+"@"+baseUrl[len("http://"):]+remoteUrl).Run()) + + require.NoError(t, os.WriteFile(filepath.Join(dest, "hello.txt"), []byte("hello"), 0644)) + require.NoError(t, exec.Command("git", "-C", dest, "add", "hello.txt").Run()) + require.NoError(t, exec.Command("git", "-C", dest, "commit", "-m", "initial").Run()) + return exec.Command("git", "-C", dest, "push", "origin").Run() + } + + tests := []struct { + name string + creds string + url string + expect bool + gistOwner string // if expect=true, verify gist exists at this owner/identifier + gistId string + }{ + {"OwnerCreates", "thomas:thomas", "/thomas/mygist.git", true, "thomas", "mygist"}, + {"OtherUserCreatesOnOwnUrl", "alice:alice", "/alice/alicegist.git", true, "alice", "alicegist"}, + {"WrongPassword", "thomas:wrong", "/thomas/newgist.git", false, "", ""}, + {"OtherUserCannotCreateOnOwner", "alice:alice", "/thomas/hackgist.git", false, "", ""}, + {"WrongUser", "aze:aze", "/aze/azegist.git", false, "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := gitInitAndPush(t, tt.creds, tt.url) + if tt.expect { + require.NoError(t, err) + gist, err := db.GetGist(tt.gistOwner, tt.gistId) + require.NoError(t, err) + require.NotNil(t, gist) + require.Equal(t, tt.gistId, gist.Identifier()) + } else { + require.Error(t, err) + } + }) + } +} diff --git a/internal/web/handlers/health/healthcheck_test.go b/internal/web/handlers/health/healthcheck_test.go new file mode 100644 index 0000000..754a636 --- /dev/null +++ b/internal/web/handlers/health/healthcheck_test.go @@ -0,0 +1,30 @@ +package health_test + +import ( + "encoding/json" + "io" + "testing" + + "github.com/stretchr/testify/require" + webtest "github.com/thomiceli/opengist/internal/web/test" +) + +func TestHealthcheck(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + t.Run("OK", func(t *testing.T) { + resp := s.Request(t, "GET", "/healthcheck", nil, 200) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(body, &result) + require.NoError(t, err) + + require.Equal(t, "ok", result["opengist"]) + require.Equal(t, "ok", result["database"]) + require.NotEmpty(t, result["time"]) + }) +} diff --git a/internal/web/test/metrics_test.go b/internal/web/handlers/metrics/metrics_test.go similarity index 66% rename from internal/web/test/metrics_test.go rename to internal/web/handlers/metrics/metrics_test.go index a425e79..4d452fd 100644 --- a/internal/web/test/metrics_test.go +++ b/internal/web/handlers/metrics/metrics_test.go @@ -1,4 +1,4 @@ -package test +package metrics_test import ( "io" @@ -10,19 +10,17 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/thomiceli/opengist/internal/db" + webtest "github.com/thomiceli/opengist/internal/web/test" ) -var ( - SSHKey = db.SSHKeyDTO{ - Title: "Test SSH Key", - Content: `ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSUGPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XAt3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/EnmZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbxNrRFi9wrf+M7Q== admin@admin.local`, - } - AdminUser = db.UserDTO{ - Username: "admin", - Password: "admin", - } +func TestMetrics(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) - SimpleGist = db.GistDTO{ + s.Register(t, "thomas") + s.Login(t, "thomas") + + s.Request(t, "POST", "/", db.GistDTO{ Title: "Simple Test Gist", Description: "A simple gist for testing", VisibilityDTO: db.VisibilityDTO{ @@ -31,39 +29,14 @@ var ( Name: []string{"file1.txt"}, Content: []string{"This is the content of file1"}, Topics: "", - } -) + }, 302) -// TestMetrics tests the metrics endpoint functionality of the application. -// It verifies that the metrics endpoint correctly reports counts for: -// - Total number of users -// - Total number of gists -// - Total number of SSH keys -// -// The test follows these steps: -// 1. Sets up test environment -// 2. Registers and logs in an admin user -// 3. Creates a gist and adds an SSH key -// 4. Creates a metrics server and queries the /metrics endpoint -// 5. Verifies the reported metrics match expected values -func TestMetrics(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) + s.Request(t, "POST", "/settings/ssh-keys", db.SSHKeyDTO{ + Title: "Test SSH Key", + Content: `ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSUGPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XAt3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/EnmZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbxNrRFi9wrf+M7Q== admin@admin.local`, + }, 302) - register(t, s, AdminUser) - login(t, s, AdminUser) - - err := s.Request("GET", "/all", nil, 200) - require.NoError(t, err) - - err = s.Request("POST", "/", SimpleGist, 302) - require.NoError(t, err) - - err = s.Request("POST", "/settings/ssh-keys", SSHKey, 302) - require.NoError(t, err) - - // Create a metrics server and query it - metricsServer := NewTestMetricsServer() + metricsServer := webtest.NewTestMetricsServer() req := httptest.NewRequest("GET", "/metrics", nil) w := httptest.NewRecorder() diff --git a/internal/web/handlers/settings/access_token_test.go b/internal/web/handlers/settings/access_token_test.go new file mode 100644 index 0000000..4f8f9dd --- /dev/null +++ b/internal/web/handlers/settings/access_token_test.go @@ -0,0 +1,332 @@ +package settings_test + +import ( + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/db" + webtest "github.com/thomiceli/opengist/internal/web/test" +) + +func TestAccessTokensCRUD(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + + t.Run("RequiresAuth", func(t *testing.T) { + s.Logout() + s.Request(t, "GET", "/settings/access-tokens", nil, 302) + }) + + t.Run("AccessTokensPage", func(t *testing.T) { + s.Login(t, "thomas") + s.Request(t, "GET", "/settings/access-tokens", nil, 200) + }) + + t.Run("CreateReadToken", func(t *testing.T) { + s.Login(t, "thomas") + s.Request(t, "POST", "/settings/access-tokens", db.AccessTokenDTO{ + Name: "test-token", + ScopeGist: db.ReadPermission, + }, 302) + + tokens, err := db.GetAccessTokensByUserID(1) + require.NoError(t, err) + require.Len(t, tokens, 1) + require.Equal(t, "test-token", tokens[0].Name) + require.Equal(t, uint(db.ReadPermission), tokens[0].ScopeGist) + require.Equal(t, int64(0), tokens[0].ExpiresAt) + }) + + t.Run("CreateExpiringToken", func(t *testing.T) { + s.Login(t, "thomas") + tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02") + s.Request(t, "POST", "/settings/access-tokens", db.AccessTokenDTO{ + Name: "expiring-token", + ScopeGist: db.ReadWritePermission, + ExpiresAt: tomorrow, + }, 302) + + tokens, err := db.GetAccessTokensByUserID(1) + require.NoError(t, err) + require.Len(t, tokens, 2) + }) + + t.Run("DeleteToken", func(t *testing.T) { + s.Login(t, "thomas") + s.Request(t, "DELETE", "/settings/access-tokens/1", nil, 302) + + tokens, err := db.GetAccessTokensByUserID(1) + require.NoError(t, err) + require.Len(t, tokens, 1) + require.Equal(t, "expiring-token", tokens[0].Name) + }) +} + +func TestAccessTokenPrivateGistAccess(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + _, _, user, identifier := s.CreateGist(t, "2") + + // Create access token with read permission + token := &db.AccessToken{ + Name: "read-token", + UserID: 1, + ScopeGist: db.ReadPermission, + } + plainToken, err := token.GenerateToken() + require.NoError(t, err) + require.NoError(t, token.Create()) + + s.Logout() + headers := map[string]string{"Authorization": "Token " + plainToken} + + t.Run("NoTokenReturns404", func(t *testing.T) { + s.Request(t, "GET", "/"+user+"/"+identifier, nil, 404) + }) + + t.Run("ValidTokenGrantsAccess", func(t *testing.T) { + s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 200, headers) + }) + + t.Run("RawContentAccessible", func(t *testing.T) { + s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier+"/raw/HEAD/file.txt", nil, 200, headers) + }) + + t.Run("JSONEndpointAccessible", func(t *testing.T) { + s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier+".json", nil, 200, headers) + }) + + t.Run("InvalidTokenReturns404", func(t *testing.T) { + s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 404, map[string]string{ + "Authorization": "Token invalid_token", + }) + }) +} + +func TestAccessTokenPermissions(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + _, _, user, identifier := s.CreateGist(t, "2") + + // Create token with NO permission + noPermToken := &db.AccessToken{ + Name: "no-perm-token", + UserID: 1, + ScopeGist: db.NoPermission, + } + noPermPlain, err := noPermToken.GenerateToken() + require.NoError(t, err) + require.NoError(t, noPermToken.Create()) + + // Create token with READ permission + readToken := &db.AccessToken{ + Name: "read-token", + UserID: 1, + ScopeGist: db.ReadPermission, + } + readPlain, err := readToken.GenerateToken() + require.NoError(t, err) + require.NoError(t, readToken.Create()) + + s.Logout() + + t.Run("NoPermissionDenied", func(t *testing.T) { + s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 404, map[string]string{ + "Authorization": "Token " + noPermPlain, + }) + }) + + t.Run("ReadPermissionGranted", func(t *testing.T) { + s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 200, map[string]string{ + "Authorization": "Token " + readPlain, + }) + }) +} + +func TestAccessTokenExpiration(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + _, _, user, identifier := s.CreateGist(t, "2") + + // Create an expired token + expiredToken := &db.AccessToken{ + Name: "expired-token", + UserID: 1, + ScopeGist: db.ReadPermission, + ExpiresAt: time.Now().Add(-24 * time.Hour).Unix(), + } + expiredPlain, err := expiredToken.GenerateToken() + require.NoError(t, err) + require.NoError(t, expiredToken.Create()) + + // Create a valid token + validToken := &db.AccessToken{ + Name: "valid-token", + UserID: 1, + ScopeGist: db.ReadPermission, + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + } + validPlain, err := validToken.GenerateToken() + require.NoError(t, err) + require.NoError(t, validToken.Create()) + + s.Logout() + + t.Run("ExpiredTokenDenied", func(t *testing.T) { + s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 404, map[string]string{ + "Authorization": "Token " + expiredPlain, + }) + }) + + t.Run("ValidTokenGranted", func(t *testing.T) { + s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 200, map[string]string{ + "Authorization": "Token " + validPlain, + }) + }) +} + +func TestAccessTokenWrongUser(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + s.Register(t, "kaguya") + + _, _, user, identifier := s.CreateGist(t, "2") + + // Create token for kaguya + kaguyaToken := &db.AccessToken{ + Name: "kaguya-token", + UserID: 2, + ScopeGist: db.ReadPermission, + } + kaguyaPlain, err := kaguyaToken.GenerateToken() + require.NoError(t, err) + require.NoError(t, kaguyaToken.Create()) + + // Create token for thomas + thomasToken := &db.AccessToken{ + Name: "thomas-token", + UserID: 1, + ScopeGist: db.ReadPermission, + } + thomasPlain, err := thomasToken.GenerateToken() + require.NoError(t, err) + require.NoError(t, thomasToken.Create()) + + s.Logout() + + t.Run("OtherUserTokenDenied", func(t *testing.T) { + s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 404, map[string]string{ + "Authorization": "Token " + kaguyaPlain, + }) + }) + + t.Run("OwnerTokenGranted", func(t *testing.T) { + s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 200, map[string]string{ + "Authorization": "Token " + thomasPlain, + }) + }) +} + +func TestAccessTokenLastUsedUpdate(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + _, _, user, identifier := s.CreateGist(t, "2") + + token := &db.AccessToken{ + Name: "test-token", + UserID: 1, + ScopeGist: db.ReadPermission, + } + plainToken, err := token.GenerateToken() + require.NoError(t, err) + require.NoError(t, token.Create()) + + // Verify LastUsedAt is 0 initially + tokenFromDB, err := db.GetAccessTokenByID(token.ID) + require.NoError(t, err) + require.Equal(t, int64(0), tokenFromDB.LastUsedAt) + + s.Logout() + + // Use the token + s.RequestWithHeaders(t, "GET", "/"+user+"/"+identifier, nil, 200, map[string]string{ + "Authorization": "Token " + plainToken, + }) + + // Verify LastUsedAt was updated + tokenFromDB, err = db.GetAccessTokenByID(token.ID) + require.NoError(t, err) + require.NotEqual(t, int64(0), tokenFromDB.LastUsedAt) +} + +func TestAccessTokenWithRequireLogin(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + _, _, user1, identifier1 := s.CreateGist(t, "2") + + s.Login(t, "thomas") + _, _, user2, identifier2 := s.CreateGist(t, "0") + + s.Login(t, "thomas") + token := &db.AccessToken{ + Name: "read-token", + UserID: 1, + ScopeGist: db.ReadPermission, + } + plainToken, err := token.GenerateToken() + require.NoError(t, err) + require.NoError(t, token.Create()) + + s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingRequireLogin}, "value": {"1"}}, 200) + s.Logout() + + headers := map[string]string{"Authorization": "Token " + plainToken} + + t.Run("UnauthenticatedRedirects", func(t *testing.T) { + s.Request(t, "GET", "/"+user1+"/"+identifier1, nil, 302) + s.Request(t, "GET", "/"+user2+"/"+identifier2, nil, 302) + }) + + t.Run("ValidTokenGrantsAccess", func(t *testing.T) { + s.RequestWithHeaders(t, "GET", "/"+user1+"/"+identifier1, nil, 200, headers) + s.RequestWithHeaders(t, "GET", "/"+user2+"/"+identifier2, nil, 200, headers) + s.RequestWithHeaders(t, "GET", "/"+user1+"/"+identifier1+"/raw/HEAD/file.txt", nil, 200, headers) + }) + + t.Run("InvalidTokenRedirects", func(t *testing.T) { + s.RequestWithHeaders(t, "GET", "/"+user1+"/"+identifier1, nil, 302, map[string]string{ + "Authorization": "Token invalid_token", + }) + }) + + t.Run("NoPermTokenRedirects", func(t *testing.T) { + noPermToken := &db.AccessToken{ + Name: "no-perm-token", + UserID: 1, + ScopeGist: db.NoPermission, + } + noPermPlain, err := noPermToken.GenerateToken() + require.NoError(t, err) + require.NoError(t, noPermToken.Create()) + + s.RequestWithHeaders(t, "GET", "/"+user1+"/"+identifier1, nil, 302, map[string]string{ + "Authorization": "Token " + noPermPlain, + }) + }) +} diff --git a/internal/web/server/middlewares.go b/internal/web/server/middlewares.go index ab26eca..137330d 100644 --- a/internal/web/server/middlewares.go +++ b/internal/web/server/middlewares.go @@ -28,7 +28,7 @@ import ( func (s *Server) useCustomContext() { s.echo.Use(func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - cc := context.NewContext(c, s.sessionsPath) + cc := context.NewContext(c, filepath.Join(config.GetHomeDir(), "sessions")) return next(cc) } }) @@ -58,29 +58,27 @@ func (s *Server) registerMiddlewares() { s.echo.Use(middleware.Recover()) s.echo.Use(middleware.Secure()) s.echo.Use(Middleware(sessionInit).toEcho()) + s.echo.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{ + TokenLookup: "form:_csrf,header:X-CSRF-Token", + CookiePath: "/", + CookieHTTPOnly: true, + CookieSameSite: http.SameSiteStrictMode, + Skipper: func(ctx echo.Context) bool { + /* skip CSRF for embeds */ + gistName := ctx.Param("gistname") - if !s.ignoreCsrf { - s.echo.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{ - TokenLookup: "form:_csrf,header:X-CSRF-Token", - CookiePath: "/", - CookieHTTPOnly: true, - CookieSameSite: http.SameSiteStrictMode, - Skipper: func(ctx echo.Context) bool { - /* skip CSRF for embeds */ - gistName := ctx.Param("gistname") + /* skip CSRF for git clients */ + matchUploadPack, _ := regexp.MatchString("(.*?)/git-upload-pack$", ctx.Request().URL.Path) + matchReceivePack, _ := regexp.MatchString("(.*?)/git-receive-pack$", ctx.Request().URL.Path) + return (filepath.Ext(gistName) == ".js" && ctx.Request().Method == "GET") || matchUploadPack || matchReceivePack + }, + ErrorHandler: func(err error, c echo.Context) error { + log.Info().Err(err).Msg("CSRF error") + return err + }, + })) + s.echo.Use(Middleware(csrfInit).toEcho()) - /* skip CSRF for git clients */ - matchUploadPack, _ := regexp.MatchString("(.*?)/git-upload-pack$", ctx.Request().URL.Path) - matchReceivePack, _ := regexp.MatchString("(.*?)/git-receive-pack$", ctx.Request().URL.Path) - return (filepath.Ext(gistName) == ".js" && ctx.Request().Method == "GET") || matchUploadPack || matchReceivePack - }, - ErrorHandler: func(err error, c echo.Context) error { - log.Info().Err(err).Msg("CSRF error") - return err - }, - })) - s.echo.Use(Middleware(csrfInit).toEcho()) - } } func (s *Server) errorHandler(err error, ctx echo.Context) { @@ -159,10 +157,10 @@ func dataInit(next Handler) Handler { func writePermission(next Handler) Handler { return func(ctx *context.Context) error { - gist := ctx.GetData("gist") + gist := ctx.GetData("gist").(*db.Gist) user := ctx.User - if !gist.(*db.Gist).CanWrite(user) { - return ctx.RedirectTo("/" + gist.(*db.Gist).User.Username + "/" + gist.(*db.Gist).Identifier()) + if !gist.CanWrite(user) { + return ctx.ErrorRes(403, "You don't have permission to edit this gist", nil) } return next(ctx) } diff --git a/internal/web/server/server.go b/internal/web/server/server.go index 96e6b52..4fdd597 100644 --- a/internal/web/server/server.go +++ b/internal/web/server/server.go @@ -2,7 +2,6 @@ package server import ( "fmt" - "github.com/thomiceli/opengist/internal/validator" "net" "net/http" "os" @@ -10,6 +9,8 @@ import ( "strconv" "strings" + "github.com/thomiceli/opengist/internal/validator" + "github.com/labstack/echo/v4" "github.com/rs/zerolog/log" "github.com/thomiceli/opengist/internal/config" @@ -18,19 +19,16 @@ import ( type Server struct { echo *echo.Echo - - dev bool - sessionsPath string - ignoreCsrf bool + dev bool } -func NewServer(isDev bool, sessionsPath string, ignoreCsrf bool) *Server { +func NewServer(isDev bool) *Server { e := echo.New() e.HideBanner = true e.HidePort = true e.Validator = validator.NewValidator() - s := &Server{echo: e, dev: isDev, sessionsPath: sessionsPath, ignoreCsrf: ignoreCsrf} + s := &Server{echo: e, dev: isDev} s.useCustomContext() @@ -175,3 +173,7 @@ func (s *Server) createPidFile(pidPath string) error { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.echo.ServeHTTP(w, r) } + +func (s *Server) Use(middleware echo.MiddlewareFunc) { + s.echo.Use(middleware) +} diff --git a/internal/web/test/access_token_test.go b/internal/web/test/access_token_test.go deleted file mode 100644 index 6887504..0000000 --- a/internal/web/test/access_token_test.go +++ /dev/null @@ -1,448 +0,0 @@ -package test - -import ( - "testing" - "time" - - "github.com/stretchr/testify/require" - "github.com/thomiceli/opengist/internal/db" -) - -func TestAccessTokensCRUD(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - // Register and login - user1 := db.UserDTO{Username: "thomas", Password: "thomas"} - register(t, s, user1) - - // Access tokens page requires login - s.sessionCookie = "" - err := s.Request("GET", "/settings/access-tokens", nil, 302) - require.NoError(t, err) - - login(t, s, user1) - - // Access tokens page - err = s.Request("GET", "/settings/access-tokens", nil, 200) - require.NoError(t, err) - - // Create a token with read permission - tokenDTO := db.AccessTokenDTO{ - Name: "test-token", - ScopeGist: db.ReadPermission, - } - err = s.Request("POST", "/settings/access-tokens", tokenDTO, 302) - require.NoError(t, err) - - // Verify token was created in database - tokens, err := db.GetAccessTokensByUserID(1) - require.NoError(t, err) - require.Len(t, tokens, 1) - require.Equal(t, "test-token", tokens[0].Name) - require.Equal(t, uint(db.ReadPermission), tokens[0].ScopeGist) - require.Equal(t, int64(0), tokens[0].ExpiresAt) - - // Create another token with expiration - tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02") - tokenDTO2 := db.AccessTokenDTO{ - Name: "expiring-token", - ScopeGist: db.ReadWritePermission, - ExpiresAt: tomorrow, - } - err = s.Request("POST", "/settings/access-tokens", tokenDTO2, 302) - require.NoError(t, err) - - tokens, err = db.GetAccessTokensByUserID(1) - require.NoError(t, err) - require.Len(t, tokens, 2) - - // Delete the first token - err = s.Request("DELETE", "/settings/access-tokens/1", nil, 302) - require.NoError(t, err) - - tokens, err = db.GetAccessTokensByUserID(1) - require.NoError(t, err) - require.Len(t, tokens, 1) - require.Equal(t, "expiring-token", tokens[0].Name) -} - -func TestAccessTokenPrivateGistAccess(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - // Register user and create a private gist - user1 := db.UserDTO{Username: "thomas", Password: "thomas"} - register(t, s, user1) - - gist1 := db.GistDTO{ - Title: "private-gist", - Description: "my private gist", - VisibilityDTO: db.VisibilityDTO{ - Private: db.PrivateVisibility, - }, - Name: []string{"secret.txt"}, - Content: []string{"secret content"}, - } - err := s.Request("POST", "/", gist1, 302) - require.NoError(t, err) - - gist1db, err := db.GetGistByID("1") - require.NoError(t, err) - - // Create access token with read permission - token := &db.AccessToken{ - Name: "read-token", - UserID: 1, - ScopeGist: db.ReadPermission, - } - plainToken, err := token.GenerateToken() - require.NoError(t, err) - err = token.Create() - require.NoError(t, err) - - // Clear session - simulate unauthenticated request - s.sessionCookie = "" - - // Without token, private gist should return 404 - err = s.Request("GET", "/thomas/"+gist1db.Uuid, nil, 404) - require.NoError(t, err) - - // With valid token, private gist should be accessible - headers := map[string]string{"Authorization": "Token " + plainToken} - err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, headers) - require.NoError(t, err) - - // Raw content should also be accessible with token - err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid+"/raw/HEAD/secret.txt", nil, 200, headers) - require.NoError(t, err) - - // JSON endpoint should also be accessible with token - err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid+".json", nil, 200, headers) - require.NoError(t, err) - - // Invalid token should not work - invalidHeaders := map[string]string{"Authorization": "Token invalid_token"} - err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, invalidHeaders) - require.NoError(t, err) -} - -func TestAccessTokenPermissions(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - // Register user and create a private gist - user1 := db.UserDTO{Username: "thomas", Password: "thomas"} - register(t, s, user1) - - gist1 := db.GistDTO{ - Title: "private-gist", - Description: "my private gist", - VisibilityDTO: db.VisibilityDTO{ - Private: db.PrivateVisibility, - }, - Name: []string{"file.txt"}, - Content: []string{"content"}, - } - err := s.Request("POST", "/", gist1, 302) - require.NoError(t, err) - - gist1db, err := db.GetGistByID("1") - require.NoError(t, err) - - // Create token with NO permission - noPermToken := &db.AccessToken{ - Name: "no-perm-token", - UserID: 1, - ScopeGist: db.NoPermission, - } - noPermPlain, err := noPermToken.GenerateToken() - require.NoError(t, err) - err = noPermToken.Create() - require.NoError(t, err) - - // Create token with READ permission - readToken := &db.AccessToken{ - Name: "read-token", - UserID: 1, - ScopeGist: db.ReadPermission, - } - readPlain, err := readToken.GenerateToken() - require.NoError(t, err) - err = readToken.Create() - require.NoError(t, err) - - s.sessionCookie = "" - - // No permission token should not grant access - noPermHeaders := map[string]string{"Authorization": "Token " + noPermPlain} - err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, noPermHeaders) - require.NoError(t, err) - - // Read permission token should grant access - readHeaders := map[string]string{"Authorization": "Token " + readPlain} - err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, readHeaders) - require.NoError(t, err) -} - -func TestAccessTokenExpiration(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - // Register user and create a private gist - user1 := db.UserDTO{Username: "thomas", Password: "thomas"} - register(t, s, user1) - - gist1 := db.GistDTO{ - Title: "private-gist", - Description: "my private gist", - VisibilityDTO: db.VisibilityDTO{ - Private: db.PrivateVisibility, - }, - Name: []string{"file.txt"}, - Content: []string{"content"}, - } - err := s.Request("POST", "/", gist1, 302) - require.NoError(t, err) - - gist1db, err := db.GetGistByID("1") - require.NoError(t, err) - - // Create an expired token - expiredToken := &db.AccessToken{ - Name: "expired-token", - UserID: 1, - ScopeGist: db.ReadPermission, - ExpiresAt: time.Now().Add(-24 * time.Hour).Unix(), // Expired yesterday - } - expiredPlain, err := expiredToken.GenerateToken() - require.NoError(t, err) - err = expiredToken.Create() - require.NoError(t, err) - - // Create a valid (non-expired) token - validToken := &db.AccessToken{ - Name: "valid-token", - UserID: 1, - ScopeGist: db.ReadPermission, - ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), // Expires tomorrow - } - validPlain, err := validToken.GenerateToken() - require.NoError(t, err) - err = validToken.Create() - require.NoError(t, err) - - s.sessionCookie = "" - - // Expired token should not grant access - expiredHeaders := map[string]string{"Authorization": "Token " + expiredPlain} - err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, expiredHeaders) - require.NoError(t, err) - - // Valid token should grant access - validHeaders := map[string]string{"Authorization": "Token " + validPlain} - err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, validHeaders) - require.NoError(t, err) -} - -func TestAccessTokenWrongUser(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - // Register two users - user1 := db.UserDTO{Username: "thomas", Password: "thomas"} - register(t, s, user1) - - // Create a private gist for user1 - gist1 := db.GistDTO{ - Title: "thomas-private-gist", - Description: "thomas private gist", - VisibilityDTO: db.VisibilityDTO{ - Private: db.PrivateVisibility, - }, - Name: []string{"file.txt"}, - Content: []string{"content"}, - } - err := s.Request("POST", "/", gist1, 302) - require.NoError(t, err) - - gist1db, err := db.GetGistByID("1") - require.NoError(t, err) - - s.sessionCookie = "" - user2 := db.UserDTO{Username: "kaguya", Password: "kaguya"} - register(t, s, user2) - - // Create token for user2 - user2Token := &db.AccessToken{ - Name: "kaguya-token", - UserID: 2, - ScopeGist: db.ReadPermission, - } - user2Plain, err := user2Token.GenerateToken() - require.NoError(t, err) - err = user2Token.Create() - require.NoError(t, err) - - s.sessionCookie = "" - - // User2's token should NOT grant access to user1's private gist - user2Headers := map[string]string{"Authorization": "Token " + user2Plain} - err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, user2Headers) - require.NoError(t, err) - - // Create token for user1 - user1Token := &db.AccessToken{ - Name: "thomas-token", - UserID: 1, - ScopeGist: db.ReadPermission, - } - user1Plain, err := user1Token.GenerateToken() - require.NoError(t, err) - err = user1Token.Create() - require.NoError(t, err) - - // User1's token SHOULD grant access to user1's private gist - user1Headers := map[string]string{"Authorization": "Token " + user1Plain} - err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, user1Headers) - require.NoError(t, err) -} - -func TestAccessTokenLastUsedUpdate(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - // Register user and create a private gist - user1 := db.UserDTO{Username: "thomas", Password: "thomas"} - register(t, s, user1) - - gist1 := db.GistDTO{ - Title: "private-gist", - Description: "my private gist", - VisibilityDTO: db.VisibilityDTO{ - Private: db.PrivateVisibility, - }, - Name: []string{"file.txt"}, - Content: []string{"content"}, - } - err := s.Request("POST", "/", gist1, 302) - require.NoError(t, err) - - gist1db, err := db.GetGistByID("1") - require.NoError(t, err) - - // Create token - token := &db.AccessToken{ - Name: "test-token", - UserID: 1, - ScopeGist: db.ReadPermission, - } - plainToken, err := token.GenerateToken() - require.NoError(t, err) - err = token.Create() - require.NoError(t, err) - - // Verify LastUsedAt is 0 initially - tokenFromDB, err := db.GetAccessTokenByID(token.ID) - require.NoError(t, err) - require.Equal(t, int64(0), tokenFromDB.LastUsedAt) - - s.sessionCookie = "" - - // Use the token - headers := map[string]string{"Authorization": "Token " + plainToken} - err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, headers) - require.NoError(t, err) - - // Verify LastUsedAt was updated - tokenFromDB, err = db.GetAccessTokenByID(token.ID) - require.NoError(t, err) - require.NotEqual(t, int64(0), tokenFromDB.LastUsedAt) -} - -func TestAccessTokenWithRequireLogin(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - admin := db.UserDTO{Username: "thomas", Password: "thomas"} - register(t, s, admin) - - gist1 := db.GistDTO{ - Title: "private-gist", - Description: "my private gist", - VisibilityDTO: db.VisibilityDTO{ - Private: db.PrivateVisibility, - }, - Name: []string{"file.txt"}, - Content: []string{"content"}, - } - err := s.Request("POST", "/", gist1, 302) - require.NoError(t, err) - - gist1db, err := db.GetGistByID("1") - require.NoError(t, err) - - gist2 := db.GistDTO{ - Title: "public-gist", - Description: "my public gist", - VisibilityDTO: db.VisibilityDTO{ - Private: db.PublicVisibility, - }, - Name: []string{"public.txt"}, - Content: []string{"public content"}, - } - err = s.Request("POST", "/", gist2, 302) - require.NoError(t, err) - - gist2db, err := db.GetGistByID("2") - require.NoError(t, err) - - token := &db.AccessToken{ - Name: "read-token", - UserID: 1, - ScopeGist: db.ReadPermission, - } - plainToken, err := token.GenerateToken() - require.NoError(t, err) - err = token.Create() - require.NoError(t, err) - - err = s.Request("PUT", "/admin-panel/set-config", settingSet{"require-login", "1"}, 200) - require.NoError(t, err) - - s.sessionCookie = "" - - err = s.Request("GET", "/thomas/"+gist1db.Uuid, nil, 302) - require.NoError(t, err) - - err = s.Request("GET", "/thomas/"+gist2db.Uuid, nil, 302) - require.NoError(t, err) - - headers := map[string]string{"Authorization": "Token " + plainToken} - err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, headers) - require.NoError(t, err) - - err = s.RequestWithHeaders("GET", "/thomas/"+gist2db.Uuid, nil, 200, headers) - require.NoError(t, err) - - err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid+"/raw/HEAD/file.txt", nil, 200, headers) - require.NoError(t, err) - - invalidHeaders := map[string]string{"Authorization": "Token invalid_token"} - err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 302, invalidHeaders) - require.NoError(t, err) - - noPermToken := &db.AccessToken{ - Name: "no-perm-token", - UserID: 1, - ScopeGist: db.NoPermission, - } - noPermPlain, err := noPermToken.GenerateToken() - require.NoError(t, err) - err = noPermToken.Create() - require.NoError(t, err) - - noPermHeaders := map[string]string{"Authorization": "Token " + noPermPlain} - err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 302, noPermHeaders) - require.NoError(t, err) -} diff --git a/internal/web/test/actions_test.go b/internal/web/test/actions_test.go deleted file mode 100644 index db7f6d0..0000000 --- a/internal/web/test/actions_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package test - -import ( - "github.com/stretchr/testify/require" - "github.com/thomiceli/opengist/internal/db" - "testing" -) - -func TestAdminActions(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - urls := []string{ - "/admin-panel/sync-fs", - "/admin-panel/sync-db", - "/admin-panel/gc-repos", - "/admin-panel/sync-previews", - "/admin-panel/reset-hooks", - "/admin-panel/index-gists", - } - - for _, url := range urls { - err := s.Request("POST", url, nil, 404) - require.NoError(t, err) - } - - user1 := db.UserDTO{Username: "admin", Password: "admin"} - register(t, s, user1) - login(t, s, user1) - for _, url := range urls { - err := s.Request("POST", url, nil, 302) - require.NoError(t, err) - } - - user2 := db.UserDTO{Username: "nonadmin", Password: "nonadmin"} - register(t, s, user2) - login(t, s, user2) - for _, url := range urls { - err := s.Request("POST", url, nil, 404) - require.NoError(t, err) - } -} diff --git a/internal/web/test/admin_test.go b/internal/web/test/admin_test.go deleted file mode 100644 index 3160737..0000000 --- a/internal/web/test/admin_test.go +++ /dev/null @@ -1,260 +0,0 @@ -package test - -import ( - "github.com/stretchr/testify/require" - "github.com/thomiceli/opengist/internal/config" - "github.com/thomiceli/opengist/internal/db" - "github.com/thomiceli/opengist/internal/git" - "os" - "path/filepath" - "strconv" - "testing" - "time" -) - -func TestAdminPages(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - urls := []string{ - "/admin-panel", - "/admin-panel/users", - "/admin-panel/gists", - "/admin-panel/invitations", - "/admin-panel/configuration", - } - - for _, url := range urls { - err := s.Request("GET", url, nil, 404) - require.NoError(t, err) - } - - user1 := db.UserDTO{Username: "admin", Password: "admin"} - register(t, s, user1) - login(t, s, user1) - for _, url := range urls { - err := s.Request("GET", url, nil, 200) - require.NoError(t, err) - } - - user2 := db.UserDTO{Username: "nonadmin", Password: "nonadmin"} - register(t, s, user2) - login(t, s, user2) - for _, url := range urls { - err := s.Request("GET", url, nil, 404) - require.NoError(t, err) - } -} - -func TestSetConfig(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - settings := []string{ - db.SettingDisableSignup, - db.SettingRequireLogin, - db.SettingAllowGistsWithoutLogin, - db.SettingDisableLoginForm, - db.SettingDisableGravatar, - } - - user1 := db.UserDTO{Username: "admin", Password: "admin"} - register(t, s, user1) - login(t, s, user1) - - for _, setting := range settings { - val, err := db.GetSetting(setting) - require.NoError(t, err) - require.Equal(t, "0", val) - - err = s.Request("PUT", "/admin-panel/set-config", settingSet{setting, "1"}, 200) - require.NoError(t, err) - - val, err = db.GetSetting(setting) - require.NoError(t, err) - require.Equal(t, "1", val) - - err = s.Request("PUT", "/admin-panel/set-config", settingSet{setting, "0"}, 200) - require.NoError(t, err) - - val, err = db.GetSetting(setting) - require.NoError(t, err) - require.Equal(t, "0", val) - } -} - -func TestPagination(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - user1 := db.UserDTO{Username: "admin", Password: "admin"} - register(t, s, user1) - for i := 0; i < 11; i++ { - user := db.UserDTO{Username: "user" + strconv.Itoa(i), Password: "user" + strconv.Itoa(i)} - register(t, s, user) - } - - login(t, s, user1) - - err := s.Request("GET", "/admin-panel/users", nil, 200) - require.NoError(t, err) - - err = s.Request("GET", "/admin-panel/users?page=2", nil, 200) - require.NoError(t, err) - - err = s.Request("GET", "/admin-panel/users?page=3", nil, 404) - require.NoError(t, err) - - err = s.Request("GET", "/admin-panel/users?page=0", nil, 200) - require.NoError(t, err) - - err = s.Request("GET", "/admin-panel/users?page=-1", nil, 200) - require.NoError(t, err) - - err = s.Request("GET", "/admin-panel/users?page=a", nil, 200) - require.NoError(t, err) -} - -func TestAdminUser(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - user1 := db.UserDTO{Username: "admin", Password: "admin"} - user2 := db.UserDTO{Username: "nonadmin", Password: "nonadmin"} - register(t, s, user1) - register(t, s, user2) - - login(t, s, user2) - - gist1 := db.GistDTO{ - Title: "gist", - VisibilityDTO: db.VisibilityDTO{ - Private: 0, - }, - Name: []string{"gist1.txt"}, - Content: []string{"yeah"}, - Topics: "", - } - err := s.Request("POST", "/", gist1, 302) - require.NoError(t, err) - - _, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, user2.Username)) - require.NoError(t, err) - - count, err := db.CountAll(db.User{}) - require.NoError(t, err) - require.Equal(t, int64(2), count) - - login(t, s, user1) - - err = s.Request("POST", "/admin-panel/users/2/delete", nil, 302) - require.NoError(t, err) - - count, err = db.CountAll(db.User{}) - require.NoError(t, err) - require.Equal(t, int64(1), count) - - _, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, user2.Username)) - require.Error(t, err) -} - -func TestAdminGist(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - user1 := db.UserDTO{Username: "admin", Password: "admin"} - register(t, s, user1) - login(t, s, user1) - - gist1 := db.GistDTO{ - Title: "gist", - VisibilityDTO: db.VisibilityDTO{ - Private: 0, - }, - Name: []string{"gist1.txt"}, - Content: []string{"yeah"}, - Topics: "", - } - err := s.Request("POST", "/", gist1, 302) - require.NoError(t, err) - - count, err := db.CountAll(db.Gist{}) - require.NoError(t, err) - require.Equal(t, int64(1), count) - - gist1Db, err := db.GetGistByID("1") - require.NoError(t, err) - - _, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, user1.Username, gist1Db.Identifier())) - require.NoError(t, err) - - err = s.Request("POST", "/admin-panel/gists/1/delete", nil, 302) - require.NoError(t, err) - - count, err = db.CountAll(db.Gist{}) - require.NoError(t, err) - require.Equal(t, int64(0), count) - - _, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, user1.Username, gist1Db.Identifier())) - require.Error(t, err) -} - -func TestAdminInvitation(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - user1 := db.UserDTO{Username: "admin", Password: "admin"} - register(t, s, user1) - login(t, s, user1) - - err := s.Request("POST", "/admin-panel/invitations", invitationAdmin{ - nbMax: "", - expiredAtUnix: "", - }, 302) - require.NoError(t, err) - invitation1, err := db.GetInvitationByID(1) - require.NoError(t, err) - require.Equal(t, uint(1), invitation1.ID) - require.Equal(t, uint(0), invitation1.NbUsed) - require.Equal(t, uint(10), invitation1.NbMax) - require.InDelta(t, time.Now().Unix()+604800, invitation1.ExpiresAt, 10) - - err = s.Request("POST", "/admin-panel/invitations", invitationAdmin{ - nbMax: "aa", - expiredAtUnix: "1735722000", - }, 302) - require.NoError(t, err) - invitation2, err := db.GetInvitationByID(2) - require.NoError(t, err) - require.Equal(t, invitation2, &db.Invitation{ - ID: 2, - Code: invitation2.Code, - ExpiresAt: time.Unix(1735722000, 0).Unix(), - NbUsed: 0, - NbMax: 10, - }) - - err = s.Request("POST", "/admin-panel/invitations", invitationAdmin{ - nbMax: "20", - expiredAtUnix: "1735722000", - }, 302) - require.NoError(t, err) - invitation3, err := db.GetInvitationByID(3) - require.NoError(t, err) - require.Equal(t, invitation3, &db.Invitation{ - ID: 3, - Code: invitation3.Code, - ExpiresAt: time.Unix(1735722000, 0).Unix(), - NbUsed: 0, - NbMax: 20, - }) - - count, err := db.CountAll(db.Invitation{}) - require.NoError(t, err) - require.Equal(t, int64(3), count) - - err = s.Request("POST", "/admin-panel/invitations/1/delete", nil, 302) - require.NoError(t, err) - - count, err = db.CountAll(db.Invitation{}) - require.NoError(t, err) - require.Equal(t, int64(2), count) -} diff --git a/internal/web/test/auth_test.go b/internal/web/test/auth_test.go deleted file mode 100644 index 41d250e..0000000 --- a/internal/web/test/auth_test.go +++ /dev/null @@ -1,414 +0,0 @@ -package test - -import ( - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/rs/zerolog/log" - "github.com/stretchr/testify/require" - "github.com/thomiceli/opengist/internal/config" - "github.com/thomiceli/opengist/internal/db" -) - -func TestRegister(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - err := s.Request("GET", "/", nil, 302) - require.NoError(t, err) - - err = s.Request("GET", "/register", nil, 200) - require.NoError(t, err) - - user1 := db.UserDTO{Username: "thomas", Password: "thomas"} - register(t, s, user1) - - user1db, err := db.GetUserById(1) - require.NoError(t, err) - require.Equal(t, user1.Username, user1db.Username) - require.True(t, user1db.IsAdmin) - - err = s.Request("GET", "/", nil, 200) - require.NoError(t, err) - - s.sessionCookie = "" - - user2 := db.UserDTO{Username: "thomas", Password: "azeaze"} - err = s.Request("POST", "/register", user2, 200) - require.Error(t, err) - - user3 := db.UserDTO{Username: "kaguya", Password: "kaguya"} - register(t, s, user3) - - user3db, err := db.GetUserById(2) - require.NoError(t, err) - require.False(t, user3db.IsAdmin) - - s.sessionCookie = "" - - count, err := db.CountAll(db.User{}) - require.NoError(t, err) - require.Equal(t, int64(2), count) -} - -func TestLogin(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - err := s.Request("GET", "/login", nil, 200) - require.NoError(t, err) - - user1 := db.UserDTO{Username: "thomas", Password: "thomas"} - register(t, s, user1) - - s.sessionCookie = "" - - login(t, s, user1) - require.NotEmpty(t, s.sessionCookie) - - s.sessionCookie = "" - - user2 := db.UserDTO{Username: "thomas", Password: "azeaze"} - user3 := db.UserDTO{Username: "azeaze", Password: ""} - - err = s.Request("POST", "/login", user2, 302) - require.Empty(t, s.sessionCookie) - require.Error(t, err) - - err = s.Request("POST", "/login", user3, 302) - require.Empty(t, s.sessionCookie) - require.Error(t, err) -} - -func register(t *testing.T, s *TestServer, user db.UserDTO) { - err := s.Request("POST", "/register", user, 302) - require.NoError(t, err) -} - -func login(t *testing.T, s *TestServer, user db.UserDTO) { - err := s.Request("POST", "/login", user, 302) - require.NoError(t, err) -} - -func TestAnonymous(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - user := db.UserDTO{Username: "thomas", Password: "azeaze"} - register(t, s, user) - - err := s.Request("PUT", "/admin-panel/set-config", settingSet{"require-login", "1"}, 200) - require.NoError(t, err) - - gist1 := db.GistDTO{ - Title: "gist1", - Description: "my first gist", - VisibilityDTO: db.VisibilityDTO{ - Private: 0, - }, - Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"}, - Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"}, - Topics: "", - } - err = s.Request("POST", "/", gist1, 302) - require.NoError(t, err) - - gist1db, err := db.GetGistByID("1") - require.NoError(t, err) - - err = s.Request("GET", "/all", nil, 200) - require.NoError(t, err) - - cookie := s.sessionCookie - s.sessionCookie = "" - - err = s.Request("GET", "/all", nil, 302) - require.NoError(t, err) - - // Should redirect to login if RequireLogin - err = s.Request("GET", "/"+gist1db.User.Username+"/"+gist1db.Uuid, nil, 302) - require.NoError(t, err) - - s.sessionCookie = cookie - - err = s.Request("PUT", "/admin-panel/set-config", settingSet{"allow-gists-without-login", "1"}, 200) - require.NoError(t, err) - - s.sessionCookie = "" - - // Should return results - err = s.Request("GET", "/"+gist1db.User.Username+"/"+gist1db.Uuid, nil, 200) - require.NoError(t, err) - -} - -func TestGitOperations(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - admin := db.UserDTO{Username: "thomas", Password: "thomas"} - register(t, s, admin) - s.sessionCookie = "" - register(t, s, db.UserDTO{Username: "fujiwara", Password: "fujiwara"}) - s.sessionCookie = "" - register(t, s, db.UserDTO{Username: "kaguya", Password: "kaguya"}) - - gist1 := db.GistDTO{ - Title: "kaguya-pub-gist", - URL: "kaguya-pub-gist", - Description: "kaguya's first gist", - VisibilityDTO: db.VisibilityDTO{ - Private: db.PublicVisibility, - }, - Name: []string{"kaguya-file.txt"}, - Content: []string{ - "yeah", - }, - Topics: "", - } - err := s.Request("POST", "/", gist1, 302) - require.NoError(t, err) - - gist2 := db.GistDTO{ - Title: "kaguya-unl-gist", - URL: "kaguya-unl-gist", - Description: "kaguya's second gist", - VisibilityDTO: db.VisibilityDTO{ - Private: db.UnlistedVisibility, - }, - Name: []string{"kaguya-file.txt"}, - Content: []string{ - "cool", - }, - Topics: "", - } - err = s.Request("POST", "/", gist2, 302) - require.NoError(t, err) - - gist3 := db.GistDTO{ - Title: "kaguya-priv-gist", - URL: "kaguya-priv-gist", - Description: "kaguya's second gist", - VisibilityDTO: db.VisibilityDTO{ - Private: db.PrivateVisibility, - }, - Name: []string{"kaguya-file.txt"}, - Content: []string{ - "super", - }, - Topics: "", - } - err = s.Request("POST", "/", gist3, 302) - require.NoError(t, err) - - tests := []struct { - credentials string - user string - url string - pushOptions string - expectErrorClone bool - expectErrorCheck bool - expectErrorPush bool - }{ - {":", "kaguya", "kaguya-pub-gist", "", false, false, true}, - {":", "kaguya", "kaguya-unl-gist", "", false, false, true}, - {":", "kaguya", "kaguya-priv-gist", "", true, true, true}, - {"kaguya:kaguya", "kaguya", "kaguya-pub-gist", "", false, false, false}, - {"kaguya:kaguya", "kaguya", "kaguya-unl-gist", "", false, false, false}, - {"kaguya:kaguya", "kaguya", "kaguya-priv-gist", "", false, false, false}, - {"fujiwara:fujiwara", "kaguya", "kaguya-pub-gist", "", false, false, true}, - {"fujiwara:fujiwara", "kaguya", "kaguya-unl-gist", "", false, false, true}, - {"fujiwara:fujiwara", "kaguya", "kaguya-priv-gist", "", true, true, true}, - } - - for _, test := range tests { - gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush) - } - - login(t, s, admin) - err = s.Request("PUT", "/admin-panel/set-config", settingSet{"require-login", "1"}, 200) - require.NoError(t, err) - - testsRequireLogin := []struct { - credentials string - user string - url string - pushOptions string - expectErrorClone bool - expectErrorCheck bool - expectErrorPush bool - }{ - {":", "kaguya", "kaguya-pub-gist", "", true, true, true}, - {":", "kaguya", "kaguya-unl-gist", "", true, true, true}, - {":", "kaguya", "kaguya-priv-gist", "", true, true, true}, - {"kaguya:kaguya", "kaguya", "kaguya-pub-gist", "", false, false, false}, - {"kaguya:kaguya", "kaguya", "kaguya-unl-gist", "", false, false, false}, - {"kaguya:kaguya", "kaguya", "kaguya-priv-gist", "", false, false, false}, - {"fujiwara:fujiwara", "kaguya", "kaguya-pub-gist", "", false, false, true}, - {"fujiwara:fujiwara", "kaguya", "kaguya-unl-gist", "", false, false, true}, - {"fujiwara:fujiwara", "kaguya", "kaguya-priv-gist", "", true, true, true}, - } - - for _, test := range testsRequireLogin { - gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush) - } - - login(t, s, admin) - err = s.Request("PUT", "/admin-panel/set-config", settingSet{"allow-gists-without-login", "1"}, 200) - require.NoError(t, err) - - for _, test := range tests { - gitCloneCheckPush(t, test.credentials, test.user, test.url, "kaguya-file.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush) - } -} - -func TestGitInit(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - admin := db.UserDTO{Username: "thomas", Password: "thomas"} - register(t, s, admin) - s.sessionCookie = "" - register(t, s, db.UserDTO{Username: "fujiwara", Password: "fujiwara"}) - s.sessionCookie = "" - register(t, s, db.UserDTO{Username: "kaguya", Password: "kaguya"}) - - testsNewWithPush := []struct { - credentials string - user string - url string - pushOptions string - expectErrorClone bool - expectErrorCheck bool - expectErrorPush bool - }{ - {":", "kaguya", "gist1", "", true, true, true}, - {"kaguya:wrongpass", "kaguya", "gist2", "", true, true, true}, - {"fujiwara:fujiwara", "kaguya", "gist3", "", true, true, true}, - {"kaguya:kaguya", "kaguya", "gist4", "", false, false, false}, - {"kaguya:kaguya", "kaguya", "gist5/g", "", true, true, true}, - } - - for _, test := range testsNewWithPush { - gitInitPush(t, test.credentials, test.user, test.url, "newfile.txt", test.pushOptions, test.expectErrorPush) - } - - gist1db, err := db.GetGistByID("1") - require.NoError(t, err) - require.Equal(t, "kaguya", gist1db.User.Username) - - for _, test := range testsNewWithPush { - gitCloneCheckPush(t, test.credentials, test.user, test.url, "newfile.txt", test.pushOptions, test.expectErrorClone, test.expectErrorCheck, test.expectErrorPush) - } - - count, err := db.CountAll(db.Gist{}) - require.NoError(t, err) - require.Equal(t, int64(1), count) - - testsNewWithInit := []struct { - credentials string - url string - pushOptions string - expectErrorPush bool - }{ - {":", "init", "", true}, - {"fujiwara:wrongpass", "init", "", true}, - {"kaguya:kaguya", "init", "", false}, - {"fujiwara:fujiwara", "init", "", false}, - } - - for _, test := range testsNewWithInit { - gitInitPush(t, test.credentials, "kaguya", test.url, "newfile.txt", test.pushOptions, test.expectErrorPush) - } - - count, err = db.CountAll(db.Gist{}) - require.NoError(t, err) - require.Equal(t, int64(3), count) - - gist2db, err := db.GetGistByID("2") - require.NoError(t, err) - require.Equal(t, "kaguya", gist2db.User.Username) - - gist3db, err := db.GetGistByID("3") - require.NoError(t, err) - require.Equal(t, "fujiwara", gist3db.User.Username) -} - -func clientGitClone(creds string, user string, url string) error { - return exec.Command("git", "clone", "http://"+creds+"@localhost:6157/"+user+"/"+url, filepath.Join(config.GetHomeDir(), "tmp", url)).Run() -} - -func clientGitPush(url string, pushOptions string, file string) error { - f, err := os.Create(filepath.Join(config.GetHomeDir(), "tmp", url, file)) - if err != nil { - return err - } - _, _ = f.WriteString("new file") - _ = f.Close() - - _ = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "add", file).Run() - _ = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "commit", "-m", "new file").Run() - if pushOptions != "" { - err = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "push", pushOptions, "origin").Run() - } else { - err = exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", url), "push", "origin").Run() - } - _ = os.RemoveAll(filepath.Join(config.GetHomeDir(), "tmp", url)) - - return err -} - -func clientGitInit(path string) error { - return exec.Command("git", "init", "--initial-branch=master", filepath.Join(config.GetHomeDir(), "tmp", path)).Run() -} - -func clientGitSetRemote(path string, remoteName string, remoteUrl string) error { - return exec.Command("git", "-C", filepath.Join(config.GetHomeDir(), "tmp", path), "remote", "add", remoteName, remoteUrl).Run() -} - -func clientCheckRepo(url string, file string) error { - _, err := os.ReadFile(filepath.Join(config.GetHomeDir(), "tmp", url, file)) - return err -} - -func gitCloneCheckPush(t *testing.T, credentials, owner, url, filename, pushOptions string, expectErrorClone, expectErrorCheck, expectErrorPush bool) { - log.Debug().Msgf("Testing %s %s %t %t %t", credentials, url, expectErrorClone, expectErrorCheck, expectErrorPush) - err := clientGitClone(credentials, owner, url) - if expectErrorClone { - require.Error(t, err) - } else { - require.NoError(t, err) - } - err = clientCheckRepo(url, filename) - if expectErrorCheck { - require.Error(t, err) - } else { - require.NoError(t, err) - } - err = clientGitPush(url, pushOptions, filename) - if expectErrorPush { - require.Error(t, err) - } else { - require.NoError(t, err) - } -} - -func gitInitPush(t *testing.T, credentials, owner, url, filename, pushOptions string, expectErrorPush bool) { - log.Debug().Msgf("Testing %s %s %t", credentials, url, expectErrorPush) - err := clientGitInit(url) - require.NoError(t, err) - if url == "init" { - err = clientGitSetRemote(url, "origin", "http://"+credentials+"@localhost:6157/init/") - } else { - err = clientGitSetRemote(url, "origin", "http://"+credentials+"@localhost:6157/"+owner+"/"+url) - } - require.NoError(t, err) - err = clientGitPush(url, pushOptions, filename) - if expectErrorPush { - require.Error(t, err) - } else { - require.NoError(t, err) - } -} diff --git a/internal/web/test/gist_test.go b/internal/web/test/gist_test.go deleted file mode 100644 index 2be379d..0000000 --- a/internal/web/test/gist_test.go +++ /dev/null @@ -1,342 +0,0 @@ -package test - -import ( - "testing" - - "github.com/stretchr/testify/require" - "github.com/thomiceli/opengist/internal/db" - "github.com/thomiceli/opengist/internal/git" -) - -func TestGists(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - err := s.Request("GET", "/", nil, 302) - require.NoError(t, err) - - user1 := db.UserDTO{Username: "thomas", Password: "thomas"} - register(t, s, user1) - - err = s.Request("GET", "/all", nil, 200) - require.NoError(t, err) - - err = s.Request("POST", "/", nil, 400) - require.NoError(t, err) - - gist1 := db.GistDTO{ - Title: "gist1", - Description: "my first gist", - VisibilityDTO: db.VisibilityDTO{ - Private: 0, - }, - Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"}, - Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"}, - Topics: "", - } - err = s.Request("POST", "/", gist1, 302) - require.NoError(t, err) - - gist1db, err := db.GetGistByID("1") - require.NoError(t, err) - require.Equal(t, uint(1), gist1db.ID) - require.Equal(t, gist1.Title, gist1db.Title) - require.Equal(t, gist1.Description, gist1db.Description) - require.Regexp(t, "[a-f0-9]{32}", gist1db.Uuid) - require.Equal(t, user1.Username, gist1db.User.Username) - - err = s.Request("GET", "/"+gist1db.User.Username+"/"+gist1db.Uuid, nil, 200) - require.NoError(t, err) - - gist1files, err := git.GetFilesOfRepository(gist1db.User.Username, gist1db.Uuid, "HEAD") - require.NoError(t, err) - require.Equal(t, 3, len(gist1files)) - - gist1fileContent, _, err := git.GetFileContent(gist1db.User.Username, gist1db.Uuid, "HEAD", gist1.Name[0], false) - require.NoError(t, err) - require.Equal(t, gist1.Content[0], gist1fileContent) - - gist2 := db.GistDTO{ - Title: "gist2", - Description: "my second gist", - VisibilityDTO: db.VisibilityDTO{ - Private: 0, - }, - Name: []string{"", "gist2.txt", "gist3.txt"}, - Content: []string{"", "yeah\ncool", "yeah\ncool gist actually"}, - Topics: "", - } - err = s.Request("POST", "/", gist2, 302) - require.NoError(t, err) - - gist3 := db.GistDTO{ - Title: "gist3", - Description: "my third gist", - VisibilityDTO: db.VisibilityDTO{ - Private: 0, - }, - Name: []string{""}, - Content: []string{"yeah"}, - Topics: "", - } - err = s.Request("POST", "/", gist3, 302) - require.NoError(t, err) - - gist3db, err := db.GetGistByID("3") - require.NoError(t, err) - - gist3files, err := git.GetFilesOfRepository(gist3db.User.Username, gist3db.Uuid, "HEAD") - require.NoError(t, err) - require.Equal(t, "gistfile1.txt", gist3files[0]) - - err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/edit", nil, 400) - require.NoError(t, err) - - gist1.Name = []string{"gist1.txt"} - gist1.Content = []string{"only want one gist"} - - err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/edit", gist1, 302) - require.NoError(t, err) - - gist1files, err = git.GetFilesOfRepository(gist1db.User.Username, gist1db.Uuid, "HEAD") - require.NoError(t, err) - require.Equal(t, 1, len(gist1files)) - - err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/delete", nil, 302) - require.NoError(t, err) -} - -func TestVisibility(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - user1 := db.UserDTO{Username: "thomas", Password: "thomas"} - register(t, s, user1) - - gist1 := db.GistDTO{ - Title: "gist1", - Description: "my first gist", - VisibilityDTO: db.VisibilityDTO{ - Private: db.UnlistedVisibility, - }, - Name: []string{""}, - Content: []string{"yeah"}, - Topics: "", - } - err := s.Request("POST", "/", gist1, 302) - require.NoError(t, err) - - gist1db, err := db.GetGistByID("1") - require.NoError(t, err) - require.Equal(t, db.UnlistedVisibility, gist1db.Private) - - err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/visibility", db.VisibilityDTO{Private: db.PrivateVisibility}, 302) - require.NoError(t, err) - gist1db, err = db.GetGistByID("1") - require.NoError(t, err) - require.Equal(t, db.PrivateVisibility, gist1db.Private) - - err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/visibility", db.VisibilityDTO{Private: db.PublicVisibility}, 302) - require.NoError(t, err) - gist1db, err = db.GetGistByID("1") - require.NoError(t, err) - require.Equal(t, db.PublicVisibility, gist1db.Private) - - err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/visibility", db.VisibilityDTO{Private: db.UnlistedVisibility}, 302) - require.NoError(t, err) - gist1db, err = db.GetGistByID("1") - require.NoError(t, err) - require.Equal(t, db.UnlistedVisibility, gist1db.Private) -} - -func TestLikeFork(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - user1 := db.UserDTO{Username: "thomas", Password: "thomas"} - register(t, s, user1) - - gist1 := db.GistDTO{ - Title: "gist1", - Description: "my first gist", - VisibilityDTO: db.VisibilityDTO{ - Private: 1, - }, - Name: []string{""}, - Content: []string{"yeah"}, - Topics: "", - } - err := s.Request("POST", "/", gist1, 302) - require.NoError(t, err) - - s.sessionCookie = "" - - user2 := db.UserDTO{Username: "kaguya", Password: "kaguya"} - register(t, s, user2) - - gist1db, err := db.GetGistByID("1") - require.NoError(t, err) - require.Equal(t, 0, gist1db.NbLikes) - likeCount, err := db.CountAll(db.Like{}) - require.NoError(t, err) - require.Equal(t, int64(0), likeCount) - - err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/like", nil, 302) - require.NoError(t, err) - gist1db, err = db.GetGistByID("1") - require.NoError(t, err) - require.Equal(t, 1, gist1db.NbLikes) - likeCount, err = db.CountAll(db.Like{}) - require.NoError(t, err) - require.Equal(t, int64(1), likeCount) - - err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/like", nil, 302) - require.NoError(t, err) - gist1db, err = db.GetGistByID("1") - require.NoError(t, err) - require.Equal(t, 0, gist1db.NbLikes) - likeCount, err = db.CountAll(db.Like{}) - require.NoError(t, err) - require.Equal(t, int64(0), likeCount) - - err = s.Request("POST", "/"+gist1db.User.Username+"/"+gist1db.Uuid+"/fork", nil, 302) - require.NoError(t, err) - gist2db, err := db.GetGistByID("2") - require.NoError(t, err) - require.Equal(t, gist1db.Title, gist2db.Title) - require.Equal(t, gist1db.Description, gist2db.Description) - require.Equal(t, gist1db.Private, gist2db.Private) - require.Equal(t, user2.Username, gist2db.User.Username) -} - -func TestCustomUrl(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - user1 := db.UserDTO{Username: "thomas", Password: "thomas"} - register(t, s, user1) - - gist1 := db.GistDTO{ - Title: "gist1", - URL: "my-gist", - Description: "my first gist", - VisibilityDTO: db.VisibilityDTO{ - Private: 0, - }, - Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"}, - Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"}, - Topics: "", - } - err := s.Request("POST", "/", gist1, 302) - require.NoError(t, err) - - gist1db, err := db.GetGistByID("1") - require.NoError(t, err) - require.Equal(t, uint(1), gist1db.ID) - require.Equal(t, gist1.Title, gist1db.Title) - require.Equal(t, gist1.Description, gist1db.Description) - require.Regexp(t, "[a-f0-9]{32}", gist1db.Uuid) - require.Equal(t, gist1.URL, gist1db.URL) - require.Equal(t, user1.Username, gist1db.User.Username) - - gist1dbUuid, err := db.GetGist(user1.Username, gist1db.Uuid) - require.NoError(t, err) - require.Equal(t, gist1db, gist1dbUuid) - - gist1dbUrl, err := db.GetGist(user1.Username, gist1.URL) - require.NoError(t, err) - require.Equal(t, gist1db, gist1dbUrl) - - require.Equal(t, gist1.URL, gist1db.Identifier()) - - gist2 := db.GistDTO{ - Title: "gist2", - Description: "my second gist", - VisibilityDTO: db.VisibilityDTO{ - Private: 0, - }, - Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"}, - Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"}, - Topics: "", - } - err = s.Request("POST", "/", gist2, 302) - require.NoError(t, err) - - gist2db, err := db.GetGistByID("2") - require.NoError(t, err) - - require.Equal(t, gist2db.Uuid, gist2db.Identifier()) - require.NotEqual(t, gist2db.URL, gist2db.Identifier()) -} - -func TestTopics(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - user1 := db.UserDTO{Username: "thomas", Password: "thomas"} - register(t, s, user1) - - gist1 := db.GistDTO{ - Title: "gist1", - URL: "my-gist", - Description: "my first gist", - VisibilityDTO: db.VisibilityDTO{ - Private: 0, - }, - Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"}, - Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"}, - Topics: "topic1 topic2 topic3", - } - err := s.Request("POST", "/", gist1, 302) - require.NoError(t, err) - - gist1db, err := db.GetGistByID("1") - require.NoError(t, err) - - require.Equal(t, []db.GistTopic{ - {GistID: 1, Topic: "topic1"}, - {GistID: 1, Topic: "topic2"}, - {GistID: 1, Topic: "topic3"}, - }, gist1db.Topics) - - gist2 := db.GistDTO{ - Title: "gist2", - URL: "my-gist", - Description: "my second gist", - VisibilityDTO: db.VisibilityDTO{ - Private: 0, - }, - Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"}, - Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"}, - Topics: "topic1 topic2 topic3 topic2 topic4 topic1", - } - err = s.Request("POST", "/", gist2, 302) - require.NoError(t, err) - - gist2db, err := db.GetGistByID("2") - require.NoError(t, err) - require.Equal(t, []db.GistTopic{ - {GistID: 2, Topic: "topic1"}, - {GistID: 2, Topic: "topic2"}, - {GistID: 2, Topic: "topic3"}, - {GistID: 2, Topic: "topic4"}, - }, gist2db.Topics) - - gist3 := db.GistDTO{ - Title: "gist3", - URL: "my-gist", - Description: "my third gist", - VisibilityDTO: db.VisibilityDTO{ - Private: 0, - }, - Name: []string{"gist1.txt", "gist2.txt", "gist3.txt"}, - Content: []string{"yeah", "yeah\ncool", "yeah\ncool gist actually"}, - Topics: "topic1 topic2 topic3 topic4 topic5 topic6 topic7 topic8 topic9 topic10 topic11", - } - err = s.Request("POST", "/", gist3, 400) - require.NoError(t, err) - - gist3.Topics = "topictoolongggggggggggggggggggggggggggggggggggggggg" - err = s.Request("POST", "/", gist3, 400) - require.NoError(t, err) -} diff --git a/internal/web/test/server.go b/internal/web/test/server.go index 50b2fad..b196562 100644 --- a/internal/web/test/server.go +++ b/internal/web/test/server.go @@ -1,8 +1,6 @@ package test import ( - "errors" - "fmt" "io" "net/http" "net/http/httptest" @@ -10,80 +8,74 @@ import ( "os" "os/exec" "path/filepath" - "reflect" - "runtime" - "strconv" "strings" "testing" - "time" - "github.com/rs/zerolog/log" + "github.com/gorilla/schema" + "github.com/labstack/echo/v4" "github.com/stretchr/testify/require" "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/git" + "github.com/thomiceli/opengist/internal/index" + "github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/handlers/metrics" "github.com/thomiceli/opengist/internal/web/server" ) var databaseType string +var formEncoder *schema.Encoder -type TestServer struct { +func init() { + formEncoder = schema.NewEncoder() + formEncoder.SetAliasTag("form") +} + +type Server struct { server *server.Server - sessionCookie string + SessionCookie string + contextData echo.Map } -func newTestServer() (*TestServer, error) { - s := &TestServer{ - server: server.NewServer(true, filepath.Join(config.GetHomeDir(), "tmp", "sessions"), true), - } - - go s.start() - return s, nil +func (s *Server) Request(t *testing.T, method, uri string, data interface{}, expectedCode int) *http.Response { + return s.RequestWithHeaders(t, method, uri, data, expectedCode, nil) } -func (s *TestServer) start() { - s.server.Start() -} - -func (s *TestServer) stop() { - s.server.Stop() -} - -func (s *TestServer) Request(method, uri string, data interface{}, expectedCode int, responsePtr ...*http.Response) error { - return s.RequestWithHeaders(method, uri, data, expectedCode, nil, responsePtr...) -} - -func (s *TestServer) RequestWithHeaders(method, uri string, data interface{}, expectedCode int, headers map[string]string, responsePtr ...*http.Response) error { +func (s *Server) RequestWithHeaders(t *testing.T, method, uri string, data interface{}, expectedCode int, headers map[string]string) *http.Response { var bodyReader io.Reader - if method == http.MethodPost || method == http.MethodPut { - values := structToURLValues(data) - bodyReader = strings.NewReader(values.Encode()) + if method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete { + if values, ok := data.(url.Values); ok { + bodyReader = strings.NewReader(values.Encode()) + } else if data != nil { + values := url.Values{} + _ = formEncoder.Encode(data, values) + bodyReader = strings.NewReader(values.Encode()) + } } - req := httptest.NewRequest(method, "http://localhost:6157"+uri, bodyReader) + req := httptest.NewRequest(method, uri, bodyReader) w := httptest.NewRecorder() if method == http.MethodPost || method == http.MethodPut { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } + req.Header.Set("Sec-Fetch-Site", "same-origin") + for key, value := range headers { req.Header.Set(key, value) } - if s.sessionCookie != "" { - req.AddCookie(&http.Cookie{Name: "session", Value: s.sessionCookie}) + if s.SessionCookie != "" { + req.AddCookie(&http.Cookie{Name: "session", Value: s.SessionCookie}) } s.server.ServeHTTP(w, req) - - if w.Code != expectedCode { - return fmt.Errorf("unexpected status code %d, expected %d", w.Code, expectedCode) + if expectedCode != 0 { + require.Equalf(t, expectedCode, w.Code, "Unexpected status code for %s %s: got %d, expected %d", method, uri, w.Code, expectedCode) } - if method == http.MethodPost { - if strings.Contains(uri, "/login") || strings.Contains(uri, "/register") { + if strings.Contains(uri, "/login") { cookie := "" h := w.Header().Get("Set-Cookie") parts := strings.Split(h, "; ") @@ -93,91 +85,127 @@ func (s *TestServer) RequestWithHeaders(method, uri string, data interface{}, ex break } } - if cookie == "" { - return errors.New("unable to find access session token in response headers") - } - s.sessionCookie = strings.TrimPrefix(cookie, "session=") + s.SessionCookie = strings.TrimPrefix(cookie, "session=") } else if strings.Contains(uri, "/logout") { - s.sessionCookie = "" + s.SessionCookie = "" } } - // If a response pointer was provided, fill it with the response data - if len(responsePtr) > 0 && responsePtr[0] != nil { - *responsePtr[0] = *w.Result() + return w.Result() +} + +func (s *Server) RawRequest(t *testing.T, req *http.Request, expectedCode int) *http.Response { + w := httptest.NewRecorder() + + req.Header.Set("Sec-Fetch-Site", "same-origin") + + if s.SessionCookie != "" { + req.AddCookie(&http.Cookie{Name: "session", Value: s.SessionCookie}) } + s.server.ServeHTTP(w, req) + + require.Equal(t, expectedCode, w.Code, "unexpected status code for %s %s", req.Method, req.URL.Path) + + return w.Result() +} + +func (s *Server) StartHttpServer(t *testing.T) string { + hs := httptest.NewServer(s.server) + t.Cleanup(hs.Close) + return hs.URL +} + +func (s *Server) User() *db.User { + s.Request(nil, "GET", "/", nil, 0) + if user, ok := s.contextData["userLogged"].(*db.User); ok { + return user + } return nil } -func structToURLValues(s interface{}) url.Values { - v := url.Values{} - if s == nil { - return v +func (s *Server) TestCtxData(t *testing.T, expected echo.Map) { + for key, expectedValue := range expected { + actualValue, exists := s.contextData[key] + require.True(t, exists, "Key %q not found in context data", key) + require.Equal(t, expectedValue, actualValue, "Context data mismatch for key %q", key) } - - rValue := reflect.ValueOf(s) - if rValue.Kind() != reflect.Struct { - return v - } - - for i := 0; i < rValue.NumField(); i++ { - field := rValue.Type().Field(i) - tag := field.Tag.Get("form") - if tag != "" || field.Anonymous { - if field.Type.Kind() == reflect.Int { - fieldValue := rValue.Field(i).Int() - v.Add(tag, strconv.FormatInt(fieldValue, 10)) - } else if field.Type.Kind() == reflect.Uint { - fieldValue := rValue.Field(i).Uint() - v.Add(tag, strconv.FormatUint(fieldValue, 10)) - } else if field.Type.Kind() == reflect.Slice { - fieldValue := rValue.Field(i).Interface().([]string) - for _, va := range fieldValue { - v.Add(tag, va) - } - } else if field.Type.Kind() == reflect.Struct { - for key, val := range structToURLValues(rValue.Field(i).Interface()) { - for _, vv := range val { - v.Add(key, vv) - } - } - } else { - fieldValue := rValue.Field(i).String() - v.Add(tag, fieldValue) - } - } - } - return v } -func Setup(t *testing.T) *TestServer { - _ = os.Setenv("OPENGIST_SKIP_GIT_HOOKS", "1") +func (s *Server) Register(t *testing.T, user string) { + s.Request(t, "POST", "/register", db.UserDTO{Username: user, Password: user}, 302) +} + +func (s *Server) Login(t *testing.T, user string) { + s.Request(t, "POST", "/login", db.UserDTO{Username: user, Password: user}, 302) +} + +func (s *Server) Logout() { + s.SessionCookie = "" +} + +func (s *Server) CreateGist(t *testing.T, visibility string) (gistPath string, gist *db.Gist, username, identifier string) { + s.Request(t, "POST", "/register", db.UserDTO{Username: "thomas", Password: "thomas"}, 0) + s.Login(t, "thomas") + + resp := s.Request(t, "POST", "/", url.Values{ + "title": {"Test"}, + "name": {"file.txt", "otherfile.txt"}, + "content": {"hello world", "other content"}, + "topics": {"hello opengist"}, + "private": {visibility}, + }, 302) + + // Extract gist identifier from redirect + location := resp.Header.Get("Location") + parts := strings.Split(strings.TrimPrefix(location, "/"), "/") + require.Len(t, parts, 2, "Expected redirect format: /{username}/{identifier}") + + gistUsername := parts[0] + gistIdentifier := parts[1] + + gist, err := db.GetGist(gistUsername, gistIdentifier) + require.NoError(t, err) + require.NotNil(t, gist) + + gistPath = filepath.Join(config.GetHomeDir(), git.ReposDirectory, "thomas", gist.Uuid) + + // Verify gist exists on filesystem + _, err = os.Stat(gistPath) + require.NoError(t, err, "Gist repository should exist at %s", gistPath) + + username = gist.User.Username + identifier = gist.Identifier() + + s.Logout() + return gistPath, gist, username, identifier +} + +func Setup(t *testing.T) *Server { + tmpDir := t.TempDir() + t.Setenv("OPENGIST_SKIP_GIT_HOOKS", "1") err := config.InitConfig("", io.Discard) require.NoError(t, err, "Could not init config") - err = os.MkdirAll(filepath.Join(config.GetHomeDir()), 0755) - require.NoError(t, err, "Could not create Opengist home directory") + config.C.LogLevel = "warn" + config.C.LogOutput = "stdout" + config.C.GitDefaultBranch = "master" + config.C.OpengistHome = tmpDir config.SetupSecretKey() - - git.ReposDirectory = filepath.Join("tests") - - config.C.Index = "" - config.C.LogLevel = "error" - config.C.GitDefaultBranch = "master" config.InitLog() + tmpGitConfig := filepath.Join(tmpDir, "gitconfig") + t.Setenv("GIT_CONFIG_GLOBAL", tmpGitConfig) + err = exec.Command("git", "config", "--global", "--type", "bool", "push.autoSetupRemote", "true").Run() require.NoError(t, err) err = exec.Command("git", "config", "--global", "user.email", "test@opengist.io").Run() require.NoError(t, err) err = exec.Command("git", "config", "--global", "user.name", "test").Run() require.NoError(t, err) - homePath := config.GetHomeDir() - log.Info().Msg("Data directory: " + homePath) var databaseDsn string databaseType = os.Getenv("OPENGIST_TEST_DB") @@ -187,70 +215,51 @@ func Setup(t *testing.T) *TestServer { case "mysql": databaseDsn = "mysql://root:opengist@localhost:3306/opengist_test" default: - databaseDsn = "file:" + filepath.Join(homePath, "tmp", "opengist_test.db") + databaseDsn = config.C.DBUri } - err = os.MkdirAll(filepath.Join(homePath, "tests"), 0755) - require.NoError(t, err, "Could not create tests directory") - - err = os.MkdirAll(filepath.Join(homePath, "tmp", "sessions"), 0755) + err = os.MkdirAll(filepath.Join(homePath, "sessions"), 0755) require.NoError(t, err, "Could not create sessions directory") + err = os.MkdirAll(filepath.Join(homePath, "repos"), 0755) + require.NoError(t, err, "Could not create repos directory") + err = os.MkdirAll(filepath.Join(homePath, "tmp", "repos"), 0755) require.NoError(t, err, "Could not create tmp repos directory") + err = os.MkdirAll(filepath.Join(homePath, "custom"), 0755) + require.NoError(t, err, "Could not create custom directory") + err = db.Setup(databaseDsn) require.NoError(t, err, "Could not initialize database") - if err != nil { - log.Fatal().Err(err).Msg("Could not initialize database") + if index.IndexEnabled() { + go index.NewIndexer(index.IndexType()) } - // err = index.Open(filepath.Join(homePath, "testsindex", "opengist.index")) - // require.NoError(t, err, "Could not open index") + s := &Server{ + server: server.NewServer(true), + } - s, err := newTestServer() - require.NoError(t, err, "Failed to create test server") + s.server.Use(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + err := next(c) + if data, ok := c.Request().Context().Value(context.DataKeyStr).(echo.Map); ok { + s.contextData = data + } + return err + } + }) return s } -func Teardown(t *testing.T, s *TestServer) { - s.stop() - - //err := db.Close() - //require.NoError(t, err, "Could not close database") - - err := db.TruncateDatabase() - require.NoError(t, err, "Could not truncate database") - - err = os.RemoveAll(filepath.Join(config.GetHomeDir(), "tests")) - require.NoError(t, err, "Could not remove repos directory") - - if runtime.GOOS == "windows" { - err = db.Close() - require.NoError(t, err, "Could not close database") - - time.Sleep(2 * time.Second) +func Teardown(t *testing.T) { + switch databaseType { + case "postgres", "mysql": + err := db.TruncateDatabase() + require.NoError(t, err, "Could not truncate database") } - err = os.RemoveAll(filepath.Join(config.GetHomeDir(), "tmp")) - require.NoError(t, err, "Could not remove tmp directory") - - // err = os.RemoveAll(path.Join(config.C.OpengistHome, "testsindex")) - // require.NoError(t, err, "Could not remove repos directory") - - // err = index.Close() - // require.NoError(t, err, "Could not close index") -} - -type settingSet struct { - key string `form:"key"` - value string `form:"value"` -} - -type invitationAdmin struct { - nbMax string `form:"nbMax"` - expiredAtUnix string `form:"expiredAtUnix"` } func NewTestMetricsServer() *metrics.Server { diff --git a/internal/web/test/settings_test.go b/internal/web/test/settings_test.go deleted file mode 100644 index 5f8fe4a..0000000 --- a/internal/web/test/settings_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package test - -import ( - "github.com/stretchr/testify/require" - "github.com/thomiceli/opengist/internal/db" - "testing" -) - -func TestSettingsPage(t *testing.T) { - s := Setup(t) - defer Teardown(t, s) - - err := s.Request("GET", "/settings", nil, 302) - require.NoError(t, err) - - user1 := db.UserDTO{Username: "thomas", Password: "thomas"} - register(t, s, user1) - login(t, s, user1) - - err = s.Request("GET", "/settings", nil, 200) - require.NoError(t, err) -} diff --git a/test.md b/test.md new file mode 100644 index 0000000..8e864e2 --- /dev/null +++ b/test.md @@ -0,0 +1,277 @@ +--- +description: Testing handler and middleware +slug: /testing +sidebar_position: 13 +--- + +# Testing + +## Testing Handler + +`GET` `/users/:id` + +Handler below retrieves user by id from the database. If user is not found it returns +`404` error with a message. + +### CreateUser + +`POST` `/users` + +- Accepts JSON payload +- On success `201 - Created` +- On error `500 - Internal Server Error` + +### GetUser + +`GET` `/users/:email` + +- On success `200 - OK` +- On error `404 - Not Found` if user is not found otherwise `500 - Internal Server Error` + +`handler.go` + +```go +package handler + +import ( + "net/http" + + "github.com/labstack/echo/v5" +) + +type ( + User struct { + Name string `json:"name" form:"name"` + Email string `json:"email" form:"email"` + } + handler struct { + db map[string]*User + } +) + +func (h *handler) createUser(c *echo.Context) error { + u := new(User) + if err := c.Bind(u); err != nil { + return err + } + return c.JSON(http.StatusCreated, u) +} + +func (h *handler) getUser(c *echo.Context) error { + email := c.Param("email") + user := h.db[email] + if user == nil { + return echo.NewHTTPError(http.StatusNotFound, "user not found") + } + return c.JSON(http.StatusOK, user) +} +``` + +`handler_test.go` + +```go +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/labstack/echo/v5/echotest" + "github.com/stretchr/testify/assert" +) + +var ( + mockDB = map[string]*User{ + "jon@labstack.com": &User{"Jon Snow", "jon@labstack.com"}, + } + userJSON = `{"name":"Jon Snow","email":"jon@labstack.com"}` +) + +func TestCreateUser(t *testing.T) { + // Setup + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(userJSON)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + h := &controller{mockDB} + + // Assertions + if assert.NoError(t, h.createUser(c)) { + assert.Equal(t, http.StatusCreated, rec.Code) + assert.Equal(t, userJSON, rec.Body.String()) + } +} + +// Same test as above but using `echotest` package helpers +func TestCreateUserWithEchoTest(t *testing.T) { + c, rec := echotest.ContextConfig{ + Headers: map[string][]string{ + echo.HeaderContentType: {echo.MIMEApplicationJSON}, + }, + JSONBody: []byte(`{"name":"Jon Snow","email":"jon@labstack.com"}`), + }.ToContextRecorder(t) + + h := &controller{mockDB} + + // Assertions + if assert.NoError(t, h.createUser(c)) { + assert.Equal(t, http.StatusCreated, rec.Code) + assert.Equal(t, userJSON+"\n", rec.Body.String()) + } +} + +// Same test as above but even shorter +func TestCreateUserWithEchoTest2(t *testing.T) { + h := &controller{mockDB} + + rec := echotest.ContextConfig{ + Headers: map[string][]string{ + echo.HeaderContentType: {echo.MIMEApplicationJSON}, + }, + JSONBody: []byte(`{"name":"Jon Snow","email":"jon@labstack.com"}`), + }.ServeWithHandler(t, h.createUser) + + assert.Equal(t, http.StatusCreated, rec.Code) + assert.Equal(t, userJSON+"\n", rec.Body.String()) +} + +func TestGetUser(t *testing.T) { + // Setup + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + c.SetPath("/users/:email") + c.SetPathValues(echo.PathValues{ + {Name: "email", Value: "jon@labstack.com"}, + }) + h := &controller{mockDB} + + // Assertions + if assert.NoError(t, h.getUser(c)) { + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, userJSON, rec.Body.String()) + } +} + +func TestGetUserWithEchoTest(t *testing.T) { + c, rec := echotest.ContextConfig{ + PathValues: echo.PathValues{ + {Name: "email", Value: "jon@labstack.com"}, + }, + Headers: map[string][]string{ + echo.HeaderContentType: {echo.MIMEApplicationJSON}, + }, + JSONBody: []byte(userJSON), + }.ToContextRecorder(t) + + h := &controller{mockDB} + + // Assertions + if assert.NoError(t, h.getUser(c)) { + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, userJSON+"\n", rec.Body.String()) + } +} +``` + +### Using Form Payload + +```go +// import "net/url" +f := make(url.Values) +f.Set("name", "Jon Snow") +f.Set("email", "jon@labstack.com") +req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(f.Encode())) +req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm) +``` + +Multipart form payload: +```go +func TestContext_MultipartForm(t *testing.T) { + testConf := echotest.ContextConfig{ + MultipartForm: &echotest.MultipartForm{ + Fields: map[string]string{ + "key": "value", + }, + Files: []echotest.MultipartFormFile{ + { + Fieldname: "file", + Filename: "test.json", + Content: echotest.LoadBytes(t, "testdata/test.json"), + }, + }, + }, + } + c := testConf.ToContext(t) + + assert.Equal(t, "value", c.FormValue("key")) + assert.Equal(t, http.MethodPost, c.Request().Method) + assert.Equal(t, true, strings.HasPrefix(c.Request().Header.Get(echo.HeaderContentType), "multipart/form-data; boundary=")) + + fv, err := c.FormFile("file") + if err != nil { + t.Fatal(err) + } + assert.Equal(t, "test.json", fv.Filename) +} +``` + +### Setting Path Params + +```go +c.SetPathValues(echo.PathValues{ + {Name: "id", Value: "1"}, + {Name: "email", Value: "jon@labstack.com"}, +}) +``` + +### Setting Query Params + +```go +// import "net/url" +q := make(url.Values) +q.Set("email", "jon@labstack.com") +req := httptest.NewRequest(http.MethodGet, "/?"+q.Encode(), nil) +``` + +## Testing Middleware + +```go +func TestCreateUserWithEchoTest2(t *testing.T) { + handler := func(c *echo.Context) error { + return c.JSON(http.StatusTeapot, fmt.Sprintf("email: %s", c.Param("email"))) + } + middleware := func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + c.Set("user_id", int64(1234)) + return next(c) + } + } + + c, rec := echotest.ContextConfig{ + PathValues: echo.PathValues{{Name: "email", Value: "jon@labstack.com"}}, + }.ToContextRecorder(t) + + err := middleware(handler)(c) + if err != nil { + t.Fatal(err) + } + // check that middleware set the value + userID, err := echo.ContextGet[int64](c, "user_id") + assert.NoError(t, err) + assert.Equal(t, int64(1234), userID) + + // check that handler returned the correct response + assert.Equal(t, http.StatusTeapot, rec.Code) +} +``` + +For now you can look into built-in middleware [test cases](https://github.com/labstack/echo/tree/master/middleware). diff --git a/test2.md b/test2.md new file mode 100644 index 0000000..cc07c50 --- /dev/null +++ b/test2.md @@ -0,0 +1,158 @@ +--- +description: Testing handler and middleware +slug: /testing +sidebar_position: 13 +--- + +# Testing + +## Testing Handler + +`GET` `/users/:id` + +Handler below retrieves user by id from the database. If user is not found it returns +`404` error with a message. + +### CreateUser + +`POST` `/users` + +- Accepts JSON payload +- On success `201 - Created` +- On error `500 - Internal Server Error` + +### GetUser + +`GET` `/users/:email` + +- On success `200 - OK` +- On error `404 - Not Found` if user is not found otherwise `500 - Internal Server Error` + +`handler.go` + +```go +package handler + +import ( + "net/http" + + "github.com/labstack/echo/v4" +) + +type ( + User struct { + Name string `json:"name" form:"name"` + Email string `json:"email" form:"email"` + } + handler struct { + db map[string]*User + } +) + +func (h *handler) createUser(c echo.Context) error { + u := new(User) + if err := c.Bind(u); err != nil { + return err + } + return c.JSON(http.StatusCreated, u) +} + +func (h *handler) getUser(c echo.Context) error { + email := c.Param("email") + user := h.db[email] + if user == nil { + return echo.NewHTTPError(http.StatusNotFound, "user not found") + } + return c.JSON(http.StatusOK, user) +} +``` + +`handler_test.go` + +```go +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" +) + +var ( + mockDB = map[string]*User{ + "jon@labstack.com": &User{"Jon Snow", "jon@labstack.com"}, + } + userJSON = `{"name":"Jon Snow","email":"jon@labstack.com"}` +) + +func TestCreateUser(t *testing.T) { + // Setup + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(userJSON)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + h := &handler{mockDB} + + // Assertions + if assert.NoError(t, h.createUser(c)) { + assert.Equal(t, http.StatusCreated, rec.Code) + assert.Equal(t, userJSON, rec.Body.String()) + } +} + +func TestGetUser(t *testing.T) { + // Setup + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/users/:email") + c.SetParamNames("email") + c.SetParamValues("jon@labstack.com") + h := &handler{mockDB} + + // Assertions + if assert.NoError(t, h.getUser(c)) { + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, userJSON, rec.Body.String()) + } +} +``` + +### Using Form Payload + +```go +// import "net/url" +f := make(url.Values) +f.Set("name", "Jon Snow") +f.Set("email", "jon@labstack.com") +req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(f.Encode())) +req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm) +``` + +### Setting Path Params + +```go +c.SetParamNames("id", "email") +c.SetParamValues("1", "jon@labstack.com") +``` + +### Setting Query Params + +```go +// import "net/url" +q := make(url.Values) +q.Set("email", "jon@labstack.com") +req := httptest.NewRequest(http.MethodGet, "/?"+q.Encode(), nil) +``` + +## Testing Middleware + +*TBD* + +For now you can look into built-in middleware [test cases](https://github.com/labstack/echo/tree/master/middleware).