validation_test.go
| 1 | // Tests for input validation: body size limits, username/password limits, |
| 2 | // tag name validation, and LIKE search wildcard escaping. |
| 3 | package e2e |
| 4 | |
| 5 | import ( |
| 6 | "net/http" |
| 7 | "net/url" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | func 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 |