diff options
Diffstat (limited to 'modules/validation')
-rw-r--r-- | modules/validation/binding.go | 209 | ||||
-rw-r--r-- | modules/validation/binding_test.go | 62 | ||||
-rw-r--r-- | modules/validation/glob_pattern_test.go | 61 | ||||
-rw-r--r-- | modules/validation/helpers.go | 136 | ||||
-rw-r--r-- | modules/validation/helpers_test.go | 216 | ||||
-rw-r--r-- | modules/validation/refname_test.go | 265 | ||||
-rw-r--r-- | modules/validation/regex_pattern_test.go | 59 | ||||
-rw-r--r-- | modules/validation/validatable.go | 84 | ||||
-rw-r--r-- | modules/validation/validatable_test.go | 69 | ||||
-rw-r--r-- | modules/validation/validurl_test.go | 110 |
10 files changed, 1271 insertions, 0 deletions
diff --git a/modules/validation/binding.go b/modules/validation/binding.go new file mode 100644 index 00000000..cb0a5063 --- /dev/null +++ b/modules/validation/binding.go @@ -0,0 +1,209 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package validation + +import ( + "fmt" + "regexp" + "strings" + + "code.gitea.io/gitea/modules/auth" + "code.gitea.io/gitea/modules/git" + + "gitea.com/go-chi/binding" + "github.com/gobwas/glob" +) + +const ( + // ErrGitRefName is git reference name error + ErrGitRefName = "GitRefNameError" + // ErrGlobPattern is returned when glob pattern is invalid + ErrGlobPattern = "GlobPattern" + // ErrRegexPattern is returned when a regex pattern is invalid + ErrRegexPattern = "RegexPattern" + // ErrUsername is username error + ErrUsername = "UsernameError" + // ErrInvalidGroupTeamMap is returned when a group team mapping is invalid + ErrInvalidGroupTeamMap = "InvalidGroupTeamMap" +) + +// AddBindingRules adds additional binding rules +func AddBindingRules() { + addGitRefNameBindingRule() + addValidURLBindingRule() + addValidSiteURLBindingRule() + addGlobPatternRule() + addRegexPatternRule() + addGlobOrRegexPatternRule() + addUsernamePatternRule() + addValidGroupTeamMapRule() +} + +func addGitRefNameBindingRule() { + // Git refname validation rule + binding.AddRule(&binding.Rule{ + IsMatch: func(rule string) bool { + return strings.HasPrefix(rule, "GitRefName") + }, + IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) { + str := fmt.Sprintf("%v", val) + + if !git.IsValidRefPattern(str) { + errs.Add([]string{name}, ErrGitRefName, "GitRefName") + return false, errs + } + return true, errs + }, + }) +} + +func addValidURLBindingRule() { + // URL validation rule + binding.AddRule(&binding.Rule{ + IsMatch: func(rule string) bool { + return strings.HasPrefix(rule, "ValidUrl") + }, + IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) { + str := fmt.Sprintf("%v", val) + if len(str) != 0 && !IsValidURL(str) { + errs.Add([]string{name}, binding.ERR_URL, "Url") + return false, errs + } + + return true, errs + }, + }) +} + +func addValidSiteURLBindingRule() { + // URL validation rule + binding.AddRule(&binding.Rule{ + IsMatch: func(rule string) bool { + return strings.HasPrefix(rule, "ValidSiteUrl") + }, + IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) { + str := fmt.Sprintf("%v", val) + if len(str) != 0 && !IsValidSiteURL(str) { + errs.Add([]string{name}, binding.ERR_URL, "Url") + return false, errs + } + + return true, errs + }, + }) +} + +func addGlobPatternRule() { + binding.AddRule(&binding.Rule{ + IsMatch: func(rule string) bool { + return rule == "GlobPattern" + }, + IsValid: globPatternValidator, + }) +} + +func globPatternValidator(errs binding.Errors, name string, val any) (bool, binding.Errors) { + str := fmt.Sprintf("%v", val) + + if len(str) != 0 { + if _, err := glob.Compile(str); err != nil { + errs.Add([]string{name}, ErrGlobPattern, err.Error()) + return false, errs + } + } + + return true, errs +} + +func addRegexPatternRule() { + binding.AddRule(&binding.Rule{ + IsMatch: func(rule string) bool { + return rule == "RegexPattern" + }, + IsValid: regexPatternValidator, + }) +} + +func regexPatternValidator(errs binding.Errors, name string, val any) (bool, binding.Errors) { + str := fmt.Sprintf("%v", val) + + if _, err := regexp.Compile(str); err != nil { + errs.Add([]string{name}, ErrRegexPattern, err.Error()) + return false, errs + } + + return true, errs +} + +func addGlobOrRegexPatternRule() { + binding.AddRule(&binding.Rule{ + IsMatch: func(rule string) bool { + return rule == "GlobOrRegexPattern" + }, + IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) { + str := strings.TrimSpace(fmt.Sprintf("%v", val)) + + if len(str) >= 2 && strings.HasPrefix(str, "/") && strings.HasSuffix(str, "/") { + return regexPatternValidator(errs, name, str[1:len(str)-1]) + } + return globPatternValidator(errs, name, val) + }, + }) +} + +func addUsernamePatternRule() { + binding.AddRule(&binding.Rule{ + IsMatch: func(rule string) bool { + return rule == "Username" + }, + IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) { + str := fmt.Sprintf("%v", val) + if !IsValidUsername(str) { + errs.Add([]string{name}, ErrUsername, "invalid username") + return false, errs + } + return true, errs + }, + }) +} + +func addValidGroupTeamMapRule() { + binding.AddRule(&binding.Rule{ + IsMatch: func(rule string) bool { + return strings.HasPrefix(rule, "ValidGroupTeamMap") + }, + IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) { + _, err := auth.UnmarshalGroupTeamMapping(fmt.Sprintf("%v", val)) + if err != nil { + errs.Add([]string{name}, ErrInvalidGroupTeamMap, err.Error()) + return false, errs + } + + return true, errs + }, + }) +} + +func portOnly(hostport string) string { + colon := strings.IndexByte(hostport, ':') + if colon == -1 { + return "" + } + if i := strings.Index(hostport, "]:"); i != -1 { + return hostport[i+len("]:"):] + } + if strings.Contains(hostport, "]") { + return "" + } + return hostport[colon+len(":"):] +} + +func validPort(p string) bool { + for _, r := range []byte(p) { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/modules/validation/binding_test.go b/modules/validation/binding_test.go new file mode 100644 index 00000000..01ff4e34 --- /dev/null +++ b/modules/validation/binding_test.go @@ -0,0 +1,62 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package validation + +import ( + "net/http" + "net/http/httptest" + "testing" + + "gitea.com/go-chi/binding" + chi "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" +) + +const ( + testRoute = "/test" +) + +type ( + validationTestCase struct { + description string + data any + expectedErrors binding.Errors + } + + TestForm struct { + BranchName string `form:"BranchName" binding:"GitRefName"` + URL string `form:"ValidUrl" binding:"ValidUrl"` + GlobPattern string `form:"GlobPattern" binding:"GlobPattern"` + RegexPattern string `form:"RegexPattern" binding:"RegexPattern"` + } +) + +func performValidationTest(t *testing.T, testCase validationTestCase) { + httpRecorder := httptest.NewRecorder() + m := chi.NewRouter() + + m.Post(testRoute, func(resp http.ResponseWriter, req *http.Request) { + actual := binding.Validate(req, testCase.data) + // see https://github.com/stretchr/testify/issues/435 + if actual == nil { + actual = binding.Errors{} + } + + assert.Equal(t, testCase.expectedErrors, actual) + }) + + req, err := http.NewRequest("POST", testRoute, nil) + if err != nil { + panic(err) + } + req.Header.Add("Content-Type", "x-www-form-urlencoded") + m.ServeHTTP(httpRecorder, req) + + switch httpRecorder.Code { + case http.StatusNotFound: + panic("Routing is messed up in test fixture (got 404): check methods and paths") + case http.StatusInternalServerError: + panic("Something bad happened on '" + testCase.description + "'") + } +} diff --git a/modules/validation/glob_pattern_test.go b/modules/validation/glob_pattern_test.go new file mode 100644 index 00000000..1bf622e6 --- /dev/null +++ b/modules/validation/glob_pattern_test.go @@ -0,0 +1,61 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package validation + +import ( + "testing" + + "gitea.com/go-chi/binding" + "github.com/gobwas/glob" +) + +func getGlobPatternErrorString(pattern string) string { + // It would be unwise to rely on that glob + // compilation errors don't ever change. + if _, err := glob.Compile(pattern); err != nil { + return err.Error() + } + return "" +} + +var globValidationTestCases = []validationTestCase{ + { + description: "Empty glob pattern", + data: TestForm{ + GlobPattern: "", + }, + expectedErrors: binding.Errors{}, + }, + { + description: "Valid glob", + data: TestForm{ + GlobPattern: "{master,release*}", + }, + expectedErrors: binding.Errors{}, + }, + + { + description: "Invalid glob", + data: TestForm{ + GlobPattern: "[a-", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"GlobPattern"}, + Classification: ErrGlobPattern, + Message: getGlobPatternErrorString("[a-"), + }, + }, + }, +} + +func Test_GlobPatternValidation(t *testing.T) { + AddBindingRules() + + for _, testCase := range globValidationTestCases { + t.Run(testCase.description, func(t *testing.T) { + performValidationTest(t, testCase) + }) + } +} diff --git a/modules/validation/helpers.go b/modules/validation/helpers.go new file mode 100644 index 00000000..567ad867 --- /dev/null +++ b/modules/validation/helpers.go @@ -0,0 +1,136 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package validation + +import ( + "net" + "net/url" + "regexp" + "strings" + + "code.gitea.io/gitea/modules/setting" + + "github.com/gobwas/glob" +) + +var externalTrackerRegex = regexp.MustCompile(`({?)(?:user|repo|index)+?(}?)`) + +func isLoopbackIP(ip string) bool { + return net.ParseIP(ip).IsLoopback() +} + +// IsValidURL checks if URL is valid +func IsValidURL(uri string) bool { + if u, err := url.ParseRequestURI(uri); err != nil || + (u.Scheme != "http" && u.Scheme != "https") || + !validPort(portOnly(u.Host)) { + return false + } + + return true +} + +// IsValidSiteURL checks if URL is valid +func IsValidSiteURL(uri string) bool { + u, err := url.ParseRequestURI(uri) + if err != nil { + return false + } + + if !validPort(portOnly(u.Host)) { + return false + } + + for _, scheme := range setting.Service.ValidSiteURLSchemes { + if scheme == u.Scheme { + return true + } + } + return false +} + +// IsEmailDomainListed checks whether the domain of an email address +// matches a list of domains +func IsEmailDomainListed(globs []glob.Glob, email string) bool { + if len(globs) == 0 { + return false + } + + n := strings.LastIndex(email, "@") + if n <= 0 { + return false + } + + domain := strings.ToLower(email[n+1:]) + + for _, g := range globs { + if g.Match(domain) { + return true + } + } + + return false +} + +// IsAPIURL checks if URL is current Gitea instance API URL +func IsAPIURL(uri string) bool { + return strings.HasPrefix(strings.ToLower(uri), strings.ToLower(setting.AppURL+"api")) +} + +// IsValidExternalURL checks if URL is valid external URL +func IsValidExternalURL(uri string) bool { + if !IsValidURL(uri) || IsAPIURL(uri) { + return false + } + + u, err := url.ParseRequestURI(uri) + if err != nil { + return false + } + + // Currently check only if not loopback IP is provided to keep compatibility + if isLoopbackIP(u.Hostname()) || strings.ToLower(u.Hostname()) == "localhost" { + return false + } + + // TODO: Later it should be added to allow local network IP addresses + // only if allowed by special setting + + return true +} + +// IsValidExternalTrackerURLFormat checks if URL matches required syntax for external trackers +func IsValidExternalTrackerURLFormat(uri string) bool { + if !IsValidExternalURL(uri) { + return false + } + + // check for typoed variables like /{index/ or /[repo} + for _, match := range externalTrackerRegex.FindAllStringSubmatch(uri, -1) { + if (match[1] == "{" || match[2] == "}") && (match[1] != "{" || match[2] != "}") { + return false + } + } + + return true +} + +var ( + validUsernamePatternWithDots = regexp.MustCompile(`^[\da-zA-Z][-.\w]*$`) + validUsernamePatternWithoutDots = regexp.MustCompile(`^[\da-zA-Z][-\w]*$`) + + // No consecutive or trailing non-alphanumeric chars, catches both cases + invalidUsernamePattern = regexp.MustCompile(`[-._]{2,}|[-._]$`) +) + +// IsValidUsername checks if username is valid +func IsValidUsername(name string) bool { + // It is difficult to find a single pattern that is both readable and effective, + // but it's easier to use positive and negative checks. + if setting.Service.AllowDotsInUsernames { + return validUsernamePatternWithDots.MatchString(name) && !invalidUsernamePattern.MatchString(name) + } + + return validUsernamePatternWithoutDots.MatchString(name) && !invalidUsernamePattern.MatchString(name) +} diff --git a/modules/validation/helpers_test.go b/modules/validation/helpers_test.go new file mode 100644 index 00000000..a1bdf2a2 --- /dev/null +++ b/modules/validation/helpers_test.go @@ -0,0 +1,216 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package validation + +import ( + "testing" + + "code.gitea.io/gitea/modules/setting" + + "github.com/stretchr/testify/assert" +) + +func Test_IsValidURL(t *testing.T) { + cases := []struct { + description string + url string + valid bool + }{ + { + description: "Empty URL", + url: "", + valid: false, + }, + { + description: "Loopback IPv4 URL", + url: "http://127.0.1.1:5678/", + valid: true, + }, + { + description: "Loopback IPv6 URL", + url: "https://[::1]/", + valid: true, + }, + { + description: "Missing semicolon after schema", + url: "http//meh/", + valid: false, + }, + } + + for _, testCase := range cases { + t.Run(testCase.description, func(t *testing.T) { + assert.Equal(t, testCase.valid, IsValidURL(testCase.url)) + }) + } +} + +func Test_IsValidExternalURL(t *testing.T) { + setting.AppURL = "https://try.gitea.io/" + + cases := []struct { + description string + url string + valid bool + }{ + { + description: "Current instance URL", + url: "https://try.gitea.io/test", + valid: true, + }, + { + description: "Loopback IPv4 URL", + url: "http://127.0.1.1:5678/", + valid: false, + }, + { + description: "Current instance API URL", + url: "https://try.gitea.io/api/v1/user/follow", + valid: false, + }, + { + description: "Local network URL", + url: "http://192.168.1.2/api/v1/user/follow", + valid: true, + }, + { + description: "Local URL", + url: "http://LOCALHOST:1234/whatever", + valid: false, + }, + } + + for _, testCase := range cases { + t.Run(testCase.description, func(t *testing.T) { + assert.Equal(t, testCase.valid, IsValidExternalURL(testCase.url)) + }) + } +} + +func Test_IsValidExternalTrackerURLFormat(t *testing.T) { + setting.AppURL = "https://try.gitea.io/" + + cases := []struct { + description string + url string + valid bool + }{ + { + description: "Correct external tracker URL with all placeholders", + url: "https://github.com/{user}/{repo}/issues/{index}", + valid: true, + }, + { + description: "Local external tracker URL with all placeholders", + url: "https://127.0.0.1/{user}/{repo}/issues/{index}", + valid: false, + }, + { + description: "External tracker URL with typo placeholder", + url: "https://github.com/{user}/{repo/issues/{index}", + valid: false, + }, + { + description: "External tracker URL with typo placeholder", + url: "https://github.com/[user}/{repo/issues/{index}", + valid: false, + }, + { + description: "External tracker URL with typo placeholder", + url: "https://github.com/{user}/repo}/issues/{index}", + valid: false, + }, + { + description: "External tracker URL missing optional placeholder", + url: "https://github.com/{user}/issues/{index}", + valid: true, + }, + { + description: "External tracker URL missing optional placeholder", + url: "https://github.com/{repo}/issues/{index}", + valid: true, + }, + { + description: "External tracker URL missing optional placeholder", + url: "https://github.com/issues/{index}", + valid: true, + }, + { + description: "External tracker URL missing optional placeholder", + url: "https://github.com/issues/{user}", + valid: true, + }, + { + description: "External tracker URL with similar placeholder names test", + url: "https://github.com/user/repo/issues/{index}", + valid: true, + }, + } + + for _, testCase := range cases { + t.Run(testCase.description, func(t *testing.T) { + assert.Equal(t, testCase.valid, IsValidExternalTrackerURLFormat(testCase.url)) + }) + } +} + +func TestIsValidUsernameAllowDots(t *testing.T) { + setting.Service.AllowDotsInUsernames = true + tests := []struct { + arg string + want bool + }{ + {arg: "a", want: true}, + {arg: "abc", want: true}, + {arg: "0.b-c", want: true}, + {arg: "a.b-c_d", want: true}, + {arg: "", want: false}, + {arg: ".abc", want: false}, + {arg: "abc.", want: false}, + {arg: "a..bc", want: false}, + {arg: "a...bc", want: false}, + {arg: "a.-bc", want: false}, + {arg: "a._bc", want: false}, + {arg: "a_-bc", want: false}, + {arg: "a/bc", want: false}, + {arg: "☁️", want: false}, + {arg: "-", want: false}, + {arg: "--diff", want: false}, + {arg: "-im-here", want: false}, + {arg: "a space", want: false}, + } + for _, tt := range tests { + t.Run(tt.arg, func(t *testing.T) { + assert.Equalf(t, tt.want, IsValidUsername(tt.arg), "IsValidUsername(%v)", tt.arg) + }) + } +} + +func TestIsValidUsernameBanDots(t *testing.T) { + setting.Service.AllowDotsInUsernames = false + defer func() { + setting.Service.AllowDotsInUsernames = true + }() + + tests := []struct { + arg string + want bool + }{ + {arg: "a", want: true}, + {arg: "abc", want: true}, + {arg: "0.b-c", want: false}, + {arg: "a.b-c_d", want: false}, + {arg: ".abc", want: false}, + {arg: "abc.", want: false}, + {arg: "a..bc", want: false}, + {arg: "a...bc", want: false}, + {arg: "a.-bc", want: false}, + {arg: "a._bc", want: false}, + } + for _, tt := range tests { + t.Run(tt.arg, func(t *testing.T) { + assert.Equalf(t, tt.want, IsValidUsername(tt.arg), "IsValidUsername[AllowDotsInUsernames=false](%v)", tt.arg) + }) + } +} diff --git a/modules/validation/refname_test.go b/modules/validation/refname_test.go new file mode 100644 index 00000000..3af7387c --- /dev/null +++ b/modules/validation/refname_test.go @@ -0,0 +1,265 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package validation + +import ( + "testing" + + "gitea.com/go-chi/binding" +) + +var gitRefNameValidationTestCases = []validationTestCase{ + { + description: "Reference name contains only characters", + data: TestForm{ + BranchName: "test", + }, + expectedErrors: binding.Errors{}, + }, + { + description: "Reference name contains single slash", + data: TestForm{ + BranchName: "feature/test", + }, + expectedErrors: binding.Errors{}, + }, + { + description: "Reference name has allowed special characters", + data: TestForm{ + BranchName: "debian/1%1.6.0-2", + }, + expectedErrors: binding.Errors{}, + }, + { + description: "Reference name contains backslash", + data: TestForm{ + BranchName: "feature\\test", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"BranchName"}, + Classification: ErrGitRefName, + Message: "GitRefName", + }, + }, + }, + { + description: "Reference name starts with dot", + data: TestForm{ + BranchName: ".test", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"BranchName"}, + Classification: ErrGitRefName, + Message: "GitRefName", + }, + }, + }, + { + description: "Reference name ends with dot", + data: TestForm{ + BranchName: "test.", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"BranchName"}, + Classification: ErrGitRefName, + Message: "GitRefName", + }, + }, + }, + { + description: "Reference name starts with slash", + data: TestForm{ + BranchName: "/test", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"BranchName"}, + Classification: ErrGitRefName, + Message: "GitRefName", + }, + }, + }, + { + description: "Reference name ends with slash", + data: TestForm{ + BranchName: "test/", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"BranchName"}, + Classification: ErrGitRefName, + Message: "GitRefName", + }, + }, + }, + { + description: "Reference name ends with .lock", + data: TestForm{ + BranchName: "test.lock", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"BranchName"}, + Classification: ErrGitRefName, + Message: "GitRefName", + }, + }, + }, + { + description: "Reference name contains multiple consecutive dots", + data: TestForm{ + BranchName: "te..st", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"BranchName"}, + Classification: ErrGitRefName, + Message: "GitRefName", + }, + }, + }, + { + description: "Reference name contains multiple consecutive slashes", + data: TestForm{ + BranchName: "te//st", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"BranchName"}, + Classification: ErrGitRefName, + Message: "GitRefName", + }, + }, + }, + { + description: "Reference name is single @", + data: TestForm{ + BranchName: "@", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"BranchName"}, + Classification: ErrGitRefName, + Message: "GitRefName", + }, + }, + }, + { + description: "Reference name has @{", + data: TestForm{ + BranchName: "branch@{", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"BranchName"}, + Classification: ErrGitRefName, + Message: "GitRefName", + }, + }, + }, + { + description: "Reference name has unallowed special character ~", + data: TestForm{ + BranchName: "~debian/1%1.6.0-2", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"BranchName"}, + Classification: ErrGitRefName, + Message: "GitRefName", + }, + }, + }, + { + description: "Reference name has unallowed special character *", + data: TestForm{ + BranchName: "*debian/1%1.6.0-2", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"BranchName"}, + Classification: ErrGitRefName, + Message: "GitRefName", + }, + }, + }, + { + description: "Reference name has unallowed special character ?", + data: TestForm{ + BranchName: "?debian/1%1.6.0-2", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"BranchName"}, + Classification: ErrGitRefName, + Message: "GitRefName", + }, + }, + }, + { + description: "Reference name has unallowed special character ^", + data: TestForm{ + BranchName: "^debian/1%1.6.0-2", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"BranchName"}, + Classification: ErrGitRefName, + Message: "GitRefName", + }, + }, + }, + { + description: "Reference name has unallowed special character :", + data: TestForm{ + BranchName: "debian:jessie", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"BranchName"}, + Classification: ErrGitRefName, + Message: "GitRefName", + }, + }, + }, + { + description: "Reference name has unallowed special character (whitespace)", + data: TestForm{ + BranchName: "debian jessie", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"BranchName"}, + Classification: ErrGitRefName, + Message: "GitRefName", + }, + }, + }, + { + description: "Reference name has unallowed special character [", + data: TestForm{ + BranchName: "debian[jessie", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"BranchName"}, + Classification: ErrGitRefName, + Message: "GitRefName", + }, + }, + }, +} + +func Test_GitRefNameValidation(t *testing.T) { + AddBindingRules() + + for _, testCase := range gitRefNameValidationTestCases { + t.Run(testCase.description, func(t *testing.T) { + performValidationTest(t, testCase) + }) + } +} diff --git a/modules/validation/regex_pattern_test.go b/modules/validation/regex_pattern_test.go new file mode 100644 index 00000000..efcb2767 --- /dev/null +++ b/modules/validation/regex_pattern_test.go @@ -0,0 +1,59 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package validation + +import ( + "regexp" + "testing" + + "gitea.com/go-chi/binding" +) + +func getRegexPatternErrorString(pattern string) string { + if _, err := regexp.Compile(pattern); err != nil { + return err.Error() + } + return "" +} + +var regexValidationTestCases = []validationTestCase{ + { + description: "Empty regex pattern", + data: TestForm{ + RegexPattern: "", + }, + expectedErrors: binding.Errors{}, + }, + { + description: "Valid regex", + data: TestForm{ + RegexPattern: `(\d{1,3})+`, + }, + expectedErrors: binding.Errors{}, + }, + + { + description: "Invalid regex", + data: TestForm{ + RegexPattern: "[a-", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"RegexPattern"}, + Classification: ErrRegexPattern, + Message: getRegexPatternErrorString("[a-"), + }, + }, + }, +} + +func Test_RegexPatternValidation(t *testing.T) { + AddBindingRules() + + for _, testCase := range regexValidationTestCases { + t.Run(testCase.description, func(t *testing.T) { + performValidationTest(t, testCase) + }) + } +} diff --git a/modules/validation/validatable.go b/modules/validation/validatable.go new file mode 100644 index 00000000..94b5cc13 --- /dev/null +++ b/modules/validation/validatable.go @@ -0,0 +1,84 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package validation + +import ( + "fmt" + "reflect" + "strings" + "unicode/utf8" + + "code.gitea.io/gitea/modules/timeutil" +) + +// ErrNotValid represents an validation error +type ErrNotValid struct { + Message string +} + +func (err ErrNotValid) Error() string { + return fmt.Sprintf("Validation Error: %v", err.Message) +} + +// IsErrNotValid checks if an error is a ErrNotValid. +func IsErrNotValid(err error) bool { + _, ok := err.(ErrNotValid) + return ok +} + +type Validateable interface { + Validate() []string +} + +func IsValid(v Validateable) (bool, error) { + if err := v.Validate(); len(err) > 0 { + typeof := reflect.TypeOf(v) + errString := strings.Join(err, "\n") + return false, ErrNotValid{fmt.Sprint(typeof, ": ", errString)} + } + + return true, nil +} + +func ValidateNotEmpty(value any, name string) []string { + isValid := true + switch v := value.(type) { + case string: + if v == "" { + isValid = false + } + case timeutil.TimeStamp: + if v.IsZero() { + isValid = false + } + case int64: + if v == 0 { + isValid = false + } + default: + isValid = false + } + + if isValid { + return []string{} + } + return []string{fmt.Sprintf("%v should not be empty", name)} +} + +func ValidateMaxLen(value string, maxLen int, name string) []string { + if utf8.RuneCountInString(value) > maxLen { + return []string{fmt.Sprintf("Value %v was longer than %v", name, maxLen)} + } + return []string{} +} + +func ValidateOneOf(value any, allowed []any, name string) []string { + for _, allowedElem := range allowed { + if value == allowedElem { + return []string{} + } + } + return []string{fmt.Sprintf("Value %v is not contained in allowed values %v", value, allowed)} +} diff --git a/modules/validation/validatable_test.go b/modules/validation/validatable_test.go new file mode 100644 index 00000000..919f5a31 --- /dev/null +++ b/modules/validation/validatable_test.go @@ -0,0 +1,69 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package validation + +import ( + "testing" + + "code.gitea.io/gitea/modules/timeutil" +) + +type Sut struct { + valid bool +} + +func (sut Sut) Validate() []string { + if sut.valid { + return []string{} + } + return []string{"invalid"} +} + +func Test_IsValid(t *testing.T) { + sut := Sut{valid: true} + if res, _ := IsValid(sut); !res { + t.Errorf("sut expected to be valid: %v\n", sut.Validate()) + } + sut = Sut{valid: false} + res, err := IsValid(sut) + if res { + t.Errorf("sut expected to be invalid: %v\n", sut.Validate()) + } + if err == nil || !IsErrNotValid(err) || err.Error() != "Validation Error: validation.Sut: invalid" { + t.Errorf("validation error expected, but was %v", err) + } +} + +func Test_ValidateNotEmpty_ForString(t *testing.T) { + sut := "" + if len(ValidateNotEmpty(sut, "dummyField")) == 0 { + t.Errorf("sut should be invalid") + } + sut = "not empty" + if res := ValidateNotEmpty(sut, "dummyField"); len(res) > 0 { + t.Errorf("sut should be valid but was %q", res) + } +} + +func Test_ValidateNotEmpty_ForTimestamp(t *testing.T) { + sut := timeutil.TimeStamp(0) + if res := ValidateNotEmpty(sut, "dummyField"); len(res) == 0 { + t.Errorf("sut should be invalid") + } + sut = timeutil.TimeStampNow() + if res := ValidateNotEmpty(sut, "dummyField"); len(res) > 0 { + t.Errorf("sut should be valid but was %q", res) + } +} + +func Test_ValidateMaxLen(t *testing.T) { + sut := "0123456789" + if len(ValidateMaxLen(sut, 9, "dummyField")) == 0 { + t.Errorf("sut should be invalid") + } + sut = "0123456789" + if res := ValidateMaxLen(sut, 11, "dummyField"); len(res) > 0 { + t.Errorf("sut should be valid but was %q", res) + } +} diff --git a/modules/validation/validurl_test.go b/modules/validation/validurl_test.go new file mode 100644 index 00000000..39f7fa5d --- /dev/null +++ b/modules/validation/validurl_test.go @@ -0,0 +1,110 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package validation + +import ( + "testing" + + "gitea.com/go-chi/binding" +) + +var urlValidationTestCases = []validationTestCase{ + { + description: "Empty URL", + data: TestForm{ + URL: "", + }, + expectedErrors: binding.Errors{}, + }, + { + description: "URL without port", + data: TestForm{ + URL: "http://test.lan/", + }, + expectedErrors: binding.Errors{}, + }, + { + description: "URL with port", + data: TestForm{ + URL: "http://test.lan:3000/", + }, + expectedErrors: binding.Errors{}, + }, + { + description: "URL with IPv6 address without port", + data: TestForm{ + URL: "http://[::1]/", + }, + expectedErrors: binding.Errors{}, + }, + { + description: "URL with IPv6 address with port", + data: TestForm{ + URL: "http://[::1]:3000/", + }, + expectedErrors: binding.Errors{}, + }, + { + description: "Invalid URL", + data: TestForm{ + URL: "http//test.lan/", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"URL"}, + Classification: binding.ERR_URL, + Message: "Url", + }, + }, + }, + { + description: "Invalid schema", + data: TestForm{ + URL: "ftp://test.lan/", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"URL"}, + Classification: binding.ERR_URL, + Message: "Url", + }, + }, + }, + { + description: "Invalid port", + data: TestForm{ + URL: "http://test.lan:3x4/", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"URL"}, + Classification: binding.ERR_URL, + Message: "Url", + }, + }, + }, + { + description: "Invalid port with IPv6 address", + data: TestForm{ + URL: "http://[::1]:3x4/", + }, + expectedErrors: binding.Errors{ + binding.Error{ + FieldNames: []string{"URL"}, + Classification: binding.ERR_URL, + Message: "Url", + }, + }, + }, +} + +func Test_ValidURLValidation(t *testing.T) { + AddBindingRules() + + for _, testCase := range urlValidationTestCases { + t.Run(testCase.description, func(t *testing.T) { + performValidationTest(t, testCase) + }) + } +} |