validation_test.go
⎇
Raw
1// Tests for input validation: body size limits, username/password limits,
2// tag name validation, and LIKE search wildcard escaping.
3package e2e
4
5import (
6 "net/http"
7 "net/url"
8 "strings"
9 "testing"
10)
11
12func TestValidation(t *testing.T) {
13 e := newEnv(t)
14 admin := e.admin()
15 cfg := e.Cfg
16
17 e.createRepo(admin, "val-repo")
18 e.seedRepo("val-repo", nil)
19
20 // A baseline issue gives the comment tests a URL.
21 issueURL := admin.post("/val-repo/issues", url.Values{
22 "title": {"Baseline issue"}, "body": {"ok"},
23 }).mustRedirect("/val-repo/issues/")
24
25 // ─── Body size limits ──────────────────────────────────────────────────
26
27 t.Run("issue body at limit is accepted", func(t *testing.T) {
28 admin.post("/val-repo/issues", url.Values{
29 "title": {"Body at limit"}, "body": {strings.Repeat("x", cfg.MaxTextBodyBytes)},
30 }).mustStatus(http.StatusFound)
31 })
32
33 t.Run("issue body over limit is rejected", func(t *testing.T) {
34 admin.post("/val-repo/issues", url.Values{
35 "title": {"Body over limit"}, "body": {strings.Repeat("x", cfg.MaxTextBodyBytes+1)},
36 }).mustStatus(http.StatusUnprocessableEntity)
37 })
38
39 t.Run("issue title at limit is accepted", func(t *testing.T) {
40 admin.post("/val-repo/issues", url.Values{
41 "title": {strings.Repeat("x", cfg.MaxTitleBytes)}, "body": {"ok"},
42 }).mustStatus(http.StatusFound)
43 })
44
45 t.Run("issue title over limit is rejected", func(t *testing.T) {
46 admin.post("/val-repo/issues", url.Values{
47 "title": {strings.Repeat("x", cfg.MaxTitleBytes+1)}, "body": {"ok"},
48 }).mustStatus(http.StatusUnprocessableEntity)
49 })
50
51 t.Run("issue comment body at limit is accepted", func(t *testing.T) {
52 admin.post(issueURL+"/comments", url.Values{
53 "body": {strings.Repeat("x", cfg.MaxTextBodyBytes)},
54 }).mustStatus(http.StatusFound)
55 })
56
57 t.Run("issue comment body over limit is rejected", func(t *testing.T) {
58 admin.post(issueURL+"/comments", url.Values{
59 "body": {strings.Repeat("x", cfg.MaxTextBodyBytes+1)},
60 }).mustStatus(http.StatusUnprocessableEntity)
61 })
62
63 t.Run("patch description at limit is accepted", func(t *testing.T) {
64 // No patch file, so the business logic rejects it. The schema check
65 // must still pass, which means anything but 422.
66 r := admin.post("/val-repo/patches", url.Values{
67 "title": {"Patch ok"}, "description": {strings.Repeat("x", cfg.MaxTextBodyBytes)},
68 })
69 if r.Code == http.StatusUnprocessableEntity {
70 t.Errorf("status = %d", r.Code)
71 }
72 })
73
74 t.Run("patch description over limit is rejected", func(t *testing.T) {
75 admin.post("/val-repo/patches", url.Values{
76 "title": {"Patch bad"}, "description": {strings.Repeat("x", cfg.MaxTextBodyBytes+1)},
77 }).mustStatus(http.StatusUnprocessableEntity)
78 })
79
80 t.Run("patch title over limit is rejected", func(t *testing.T) {
81 admin.post("/val-repo/patches", url.Values{
82 "title": {strings.Repeat("x", cfg.MaxTitleBytes+1)},
83 }).mustStatus(http.StatusUnprocessableEntity)
84 })
85
86 // ─── Auth limits ───────────────────────────────────────────────────────
87
88 t.Run("username over limit is rejected at registration", func(t *testing.T) {
89 e.anon().post("/register", url.Values{
90 "username": {strings.Repeat("u", cfg.MaxUsernameBytes+1)},
91 "password": {"validpass1"},
92 "password2": {"validpass1"},
93 }).mustStatus(http.StatusUnprocessableEntity)
94 })
95
96 t.Run("username at limit is not schema-rejected", func(t *testing.T) {
97 // A username at exactly the limit passes the schema check. It may
98 // still fail on uniqueness or format, so only 422 is wrong.
99 r := e.anon().post("/register", url.Values{
100 "username": {strings.Repeat("a", cfg.MaxUsernameBytes)},
101 "password": {"validpass1"},
102 "password2": {"validpass1"},
103 })
104 if r.Code == http.StatusUnprocessableEntity {
105 t.Errorf("status = %d", r.Code)
106 }
107 })
108
109 t.Run("password over limit is rejected at registration", func(t *testing.T) {
110 long := strings.Repeat("p", cfg.MaxPasswordBytes+1)
111 e.anon().post("/register", url.Values{
112 "username": {"newuser"}, "password": {long}, "password2": {long},
113 }).mustStatus(http.StatusUnprocessableEntity)
114 })
115
116 t.Run("new_password over limit is rejected at settings/password", func(t *testing.T) {
117 long := strings.Repeat("p", cfg.MaxPasswordBytes+1)
118 admin.post("/settings/password", url.Values{
119 "current_password": {adminPass}, "new_password": {long}, "confirm_password": {long},
120 }).mustStatus(http.StatusUnprocessableEntity)
121 })
122
123 // ─── Tag name validation ───────────────────────────────────────────────
124
125 for _, tag := range []string{"v1.0.0", "release-2", "1.0+build.1", "v1_alpha"} {
126 t.Run("valid tag "+tag+" is accepted", func(t *testing.T) {
127 // 302 on success, 200 with a form error (e.g. tag exists) is also
128 // fine. Only a 422 schema error is wrong.
129 r := admin.post("/val-repo/releases", url.Values{
130 "create_tag": {"on"}, "tag_name": {tag},
131 "revision": {"main"}, "name": {"Release " + tag},
132 })
133 if r.Code == http.StatusUnprocessableEntity {
134 t.Errorf("status = %d", r.Code)
135 }
136 })
137 }
138
139 for _, tag := range []string{"v1.0~1", "tag with space", "v1:2", "v1^2", "ref/head", "v1?", "v1*"} {
140 t.Run("invalid tag "+tag+" is rejected", func(t *testing.T) {
141 r := admin.post("/val-repo/releases", url.Values{
142 "create_tag": {"on"}, "tag_name": {tag},
143 "revision": {"main"}, "name": {"Release " + tag},
144 }).mustStatus(http.StatusOK)
145 if !r.Contains("may only contain") {
146 t.Error("inline form error missing")
147 }
148 })
149 }
150
151 // ─── LIKE wildcard escaping in repo search ─────────────────────────────
152
153 t.Run("search for _ returns only repos with literal underscore", func(t *testing.T) {
154 e.createRepo(admin, "search-under_score")
155 e.createRepo(admin, "search-nodash")
156
157 body := admin.get("/?q=" + url.QueryEscape("_")).BodyString()
158 if !strings.Contains(body, "search-under_score") {
159 t.Error("search-under_score missing")
160 }
161 for _, bad := range []string{"search-nodash", "val-repo"} {
162 if strings.Contains(body, bad) {
163 t.Errorf("body contains %q", bad)
164 }
165 }
166 })
167
168 t.Run("search for % returns no repos", func(t *testing.T) {
169 body := admin.get("/?q=" + url.QueryEscape("%")).BodyString()
170 for _, bad := range []string{"search-under_score", "search-nodash", "val-repo"} {
171 if strings.Contains(body, bad) {
172 t.Errorf("body contains %q", bad)
173 }
174 }
175 })
176
177 t.Run("normal substring search still works", func(t *testing.T) {
178 body := admin.get("/?q=search-under").BodyString()
179 if !strings.Contains(body, "search-under_score") {
180 t.Error("search-under_score missing")
181 }
182 if strings.Contains(body, "search-nodash") {
183 t.Error("body contains search-nodash")
184 }
185 })
186}
187