Go · 12392 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package orgs_test
4
5 import (
6 "bytes"
7 "context"
8 "html"
9 "image"
10 "image/color"
11 "image/png"
12 "io"
13 "log/slog"
14 "mime/multipart"
15 "net/http"
16 "net/http/httptest"
17 "net/url"
18 "strings"
19 "testing"
20 "testing/fstest"
21
22 "github.com/go-chi/chi/v5"
23
24 "github.com/tenseleyFlow/shithub/internal/infra/storage"
25 orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
26 "github.com/tenseleyFlow/shithub/internal/testing/dbtest"
27 orgsh "github.com/tenseleyFlow/shithub/internal/web/handlers/orgs"
28 "github.com/tenseleyFlow/shithub/internal/web/middleware"
29 "github.com/tenseleyFlow/shithub/internal/web/render"
30 )
31
32 func TestOrgAvatarUploadRoundTrip(t *testing.T) {
33 t.Parallel()
34 ctx := context.Background()
35 pool := dbtest.NewTestDB(t)
36 q := orgsdb.New()
37 viewerID := insertOrgAvatarUser(t, pool, "mfwolffe")
38 orgID := insertOrgAvatarOrg(t, pool, viewerID, "tenseleyFlow")
39
40 tmplFS := fstest.MapFS{
41 "_layout.html": {Data: []byte(`{{ define "layout" }}<html><body>{{ template "page" . }}</body></html>{{ end }}`)},
42 "orgs/settings_profile.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}<form action="/organizations/{{ .Org.Slug }}/settings/profile/avatar"><input name=csrf_token value="{{.CSRFToken}}"></form>{{ if .HasAvatar }}REMOVE=/organizations/{{ .Org.Slug }}/settings/profile/avatar/remove{{ end }}{{ end }}`)},
43 "errors/403.html": {Data: []byte(`{{ define "page" }}403{{ end }}`)},
44 "errors/404.html": {Data: []byte(`{{ define "page" }}404{{ end }}`)},
45 "errors/500.html": {Data: []byte(`{{ define "page" }}500{{ end }}`)},
46 }
47 rr, err := render.New(tmplFS, render.Options{})
48 if err != nil {
49 t.Fatalf("render.New: %v", err)
50 }
51 h, err := orgsh.New(orgsh.Deps{
52 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
53 Render: rr,
54 Pool: pool,
55 ObjectStore: storage.NewMemoryStore(),
56 })
57 if err != nil {
58 t.Fatalf("orgsh.New: %v", err)
59 }
60 r := chi.NewRouter()
61 r.Use(func(next http.Handler) http.Handler {
62 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
63 viewer := middleware.CurrentUser{ID: viewerID, Username: "mfwolffe"}
64 next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer)))
65 })
66 })
67 h.MountCreate(r)
68 srv := httptest.NewServer(r)
69 t.Cleanup(srv.Close)
70
71 cli := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
72 return http.ErrUseLastResponse
73 }}
74
75 resp, err := cli.Get(srv.URL + "/organizations/tenseleyFlow/settings/profile")
76 if err != nil {
77 t.Fatalf("GET settings: %v", err)
78 }
79 body, _ := io.ReadAll(resp.Body)
80 _ = resp.Body.Close()
81 if resp.StatusCode != http.StatusOK {
82 t.Fatalf("GET status=%d body=%s", resp.StatusCode, body)
83 }
84 if !strings.Contains(string(body), "/organizations/tenseleyFlow/settings/profile/avatar") {
85 t.Fatalf("expected upload form, got %s", body)
86 }
87
88 resp = postOrgAvatar(t, cli, srv.URL+"/organizations/tenseleyFlow/settings/profile/avatar", makeOrgTestPNG(t))
89 _ = resp.Body.Close()
90 if resp.StatusCode != http.StatusSeeOther {
91 t.Fatalf("upload status=%d", resp.StatusCode)
92 }
93 if got := resp.Header.Get("Location"); got != "/organizations/tenseleyFlow/settings/profile" {
94 t.Fatalf("upload Location=%q", got)
95 }
96 org, err := q.GetOrgByID(ctx, pool, orgID)
97 if err != nil {
98 t.Fatalf("GetOrgByID: %v", err)
99 }
100 if !org.AvatarObjectKey.Valid || !strings.HasPrefix(org.AvatarObjectKey.String, "avatars/orgs/") {
101 t.Fatalf("avatar key=%q valid=%v", org.AvatarObjectKey.String, org.AvatarObjectKey.Valid)
102 }
103
104 resp, err = cli.PostForm(srv.URL+"/organizations/tenseleyFlow/settings/profile/avatar/remove", url.Values{})
105 if err != nil {
106 t.Fatalf("POST remove: %v", err)
107 }
108 _ = resp.Body.Close()
109 if resp.StatusCode != http.StatusSeeOther {
110 t.Fatalf("remove status=%d", resp.StatusCode)
111 }
112 org, err = q.GetOrgByID(ctx, pool, orgID)
113 if err != nil {
114 t.Fatalf("GetOrgByID after remove: %v", err)
115 }
116 if org.AvatarObjectKey.Valid {
117 t.Fatalf("expected cleared avatar key, got %q", org.AvatarObjectKey.String)
118 }
119 }
120
121 func TestOrgSettingsProfileUpdate(t *testing.T) {
122 t.Parallel()
123 ctx := context.Background()
124 pool := dbtest.NewTestDB(t)
125 q := orgsdb.New()
126 viewerID := insertOrgAvatarUser(t, pool, "mfwolffe")
127 orgID := insertOrgAvatarOrg(t, pool, viewerID, "tenseleyFlow")
128
129 tmplFS := fstest.MapFS{
130 "_layout.html": {Data: []byte(`{{ define "layout" }}<html><body>{{ template "page" . }}</body></html>{{ end }}`)},
131 "orgs/settings_profile.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ with .Success }}SUCCESS={{ . }}{{ end }}DISPLAY={{ .Form.DisplayName }}{{ end }}`)},
132 "errors/403.html": {Data: []byte(`{{ define "page" }}403{{ end }}`)},
133 "errors/404.html": {Data: []byte(`{{ define "page" }}404{{ end }}`)},
134 "errors/500.html": {Data: []byte(`{{ define "page" }}500{{ end }}`)},
135 }
136 rr, err := render.New(tmplFS, render.Options{})
137 if err != nil {
138 t.Fatalf("render.New: %v", err)
139 }
140 h, err := orgsh.New(orgsh.Deps{
141 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
142 Render: rr,
143 Pool: pool,
144 })
145 if err != nil {
146 t.Fatalf("orgsh.New: %v", err)
147 }
148 r := chi.NewRouter()
149 r.Use(func(next http.Handler) http.Handler {
150 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
151 viewer := middleware.CurrentUser{ID: viewerID, Username: "mfwolffe"}
152 next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer)))
153 })
154 })
155 h.MountCreate(r)
156 srv := httptest.NewServer(r)
157 t.Cleanup(srv.Close)
158
159 resp, err := http.PostForm(srv.URL+"/organizations/tenseleyFlow/settings/profile", url.Values{
160 "display_name": {"Tenseley Flow"},
161 "description": {"Workflow repositories"},
162 "website": {"example.com"},
163 "location": {"United States of America"},
164 "billing_email": {"billing@example.com"},
165 "allow_member_repo_create": {"on"},
166 })
167 if err != nil {
168 t.Fatalf("POST settings: %v", err)
169 }
170 body, _ := io.ReadAll(resp.Body)
171 _ = resp.Body.Close()
172 if resp.StatusCode != http.StatusOK {
173 t.Fatalf("POST status=%d body=%s", resp.StatusCode, body)
174 }
175 if !strings.Contains(string(body), "SUCCESS=Organization profile updated.") {
176 t.Fatalf("expected success render, got %s", body)
177 }
178 org, err := q.GetOrgByID(ctx, pool, orgID)
179 if err != nil {
180 t.Fatalf("GetOrgByID: %v", err)
181 }
182 if org.DisplayName != "Tenseley Flow" ||
183 org.Description != "Workflow repositories" ||
184 org.Website != "https://example.com" ||
185 org.Location != "United States of America" ||
186 org.BillingEmail != "billing@example.com" ||
187 !org.AllowMemberRepoCreate {
188 t.Fatalf("unexpected org after update: %#v", org)
189 }
190
191 resp, err = http.PostForm(srv.URL+"/organizations/tenseleyFlow/settings/profile", url.Values{
192 "display_name": {"Tenseley Flow"},
193 "billing_email": {"billing@example.com"},
194 })
195 if err != nil {
196 t.Fatalf("POST settings clear checkbox: %v", err)
197 }
198 _ = resp.Body.Close()
199 org, err = q.GetOrgByID(ctx, pool, orgID)
200 if err != nil {
201 t.Fatalf("GetOrgByID after checkbox clear: %v", err)
202 }
203 if org.AllowMemberRepoCreate {
204 t.Fatalf("expected unchecked allow_member_repo_create to persist false")
205 }
206 }
207
208 func TestOrgSettingsDeleteRequiresSlugConfirmation(t *testing.T) {
209 t.Parallel()
210 ctx := context.Background()
211 pool := dbtest.NewTestDB(t)
212 q := orgsdb.New()
213 viewerID := insertOrgAvatarUser(t, pool, "mfwolffe")
214 insertOrgAvatarOrg(t, pool, viewerID, "tenseleyFlow")
215
216 tmplFS := fstest.MapFS{
217 "_layout.html": {Data: []byte(`{{ define "layout" }}<html><body>{{ template "page" . }}</body></html>{{ end }}`)},
218 "orgs/settings_profile.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ end }}`)},
219 "errors/403.html": {Data: []byte(`{{ define "page" }}403{{ end }}`)},
220 "errors/404.html": {Data: []byte(`{{ define "page" }}404{{ end }}`)},
221 "errors/500.html": {Data: []byte(`{{ define "page" }}500{{ end }}`)},
222 }
223 rr, err := render.New(tmplFS, render.Options{})
224 if err != nil {
225 t.Fatalf("render.New: %v", err)
226 }
227 h, err := orgsh.New(orgsh.Deps{
228 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
229 Render: rr,
230 Pool: pool,
231 })
232 if err != nil {
233 t.Fatalf("orgsh.New: %v", err)
234 }
235 r := chi.NewRouter()
236 r.Use(func(next http.Handler) http.Handler {
237 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
238 viewer := middleware.CurrentUser{ID: viewerID, Username: "mfwolffe"}
239 next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer)))
240 })
241 })
242 h.MountCreate(r)
243 srv := httptest.NewServer(r)
244 t.Cleanup(srv.Close)
245 cli := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
246 return http.ErrUseLastResponse
247 }}
248
249 resp, err := cli.PostForm(srv.URL+"/organizations/tenseleyFlow/settings/delete", url.Values{
250 "confirm_slug": {"wrong"},
251 })
252 if err != nil {
253 t.Fatalf("POST delete wrong confirmation: %v", err)
254 }
255 body, _ := io.ReadAll(resp.Body)
256 _ = resp.Body.Close()
257 if resp.StatusCode != http.StatusOK {
258 t.Fatalf("wrong confirmation status=%d body=%s", resp.StatusCode, body)
259 }
260 if !strings.Contains(html.UnescapeString(string(body)), "ERROR=Enter this organization's name to confirm deletion.") {
261 t.Fatalf("expected confirmation error, got %s", body)
262 }
263 org, err := q.GetOrgBySlugIncludingDeleted(ctx, pool, "tenseleyFlow")
264 if err != nil {
265 t.Fatalf("GetOrgBySlugIncludingDeleted: %v", err)
266 }
267 if org.DeletedAt.Valid {
268 t.Fatalf("org should not be deleted after wrong confirmation")
269 }
270
271 resp, err = cli.PostForm(srv.URL+"/organizations/tenseleyFlow/settings/delete", url.Values{
272 "confirm_slug": {"TENSELEYFLOW"},
273 })
274 if err != nil {
275 t.Fatalf("POST delete: %v", err)
276 }
277 _ = resp.Body.Close()
278 if resp.StatusCode != http.StatusSeeOther {
279 t.Fatalf("delete status=%d", resp.StatusCode)
280 }
281 if got := resp.Header.Get("Location"); got != "/settings/organizations" {
282 t.Fatalf("delete Location=%q", got)
283 }
284 org, err = q.GetOrgBySlugIncludingDeleted(ctx, pool, "tenseleyFlow")
285 if err != nil {
286 t.Fatalf("GetOrgBySlugIncludingDeleted after delete: %v", err)
287 }
288 if !org.DeletedAt.Valid {
289 t.Fatalf("expected org to be soft-deleted")
290 }
291 }
292
293 func postOrgAvatar(t *testing.T, cli *http.Client, endpoint string, png []byte) *http.Response {
294 t.Helper()
295 body := &bytes.Buffer{}
296 mw := multipart.NewWriter(body)
297 part, err := mw.CreateFormFile("avatar", "avatar.png")
298 if err != nil {
299 t.Fatalf("CreateFormFile: %v", err)
300 }
301 if _, err := part.Write(png); err != nil {
302 t.Fatalf("write png: %v", err)
303 }
304 if err := mw.Close(); err != nil {
305 t.Fatalf("close multipart: %v", err)
306 }
307 req, err := http.NewRequest(http.MethodPost, endpoint, body)
308 if err != nil {
309 t.Fatalf("NewRequest: %v", err)
310 }
311 req.Header.Set("Content-Type", mw.FormDataContentType())
312 resp, err := cli.Do(req)
313 if err != nil {
314 t.Fatalf("POST avatar: %v", err)
315 }
316 return resp
317 }
318
319 func makeOrgTestPNG(t *testing.T) []byte {
320 t.Helper()
321 img := image.NewRGBA(image.Rect(0, 0, 64, 64))
322 for y := 0; y < 64; y++ {
323 for x := 0; x < 64; x++ {
324 img.Set(x, y, color.RGBA{R: 80, G: 30, B: 110, A: 255})
325 }
326 }
327 buf := &bytes.Buffer{}
328 if err := png.Encode(buf, img); err != nil {
329 t.Fatalf("encode png: %v", err)
330 }
331 return buf.Bytes()
332 }
333
334 func insertOrgAvatarUser(t *testing.T, db orgsdb.DBTX, username string) int64 {
335 t.Helper()
336 var id int64
337 if err := db.QueryRow(context.Background(),
338 `INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id`,
339 username,
340 "$argon2id$v=19$m=16384,t=1,p=1$AAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
341 ).Scan(&id); err != nil {
342 t.Fatalf("insert user: %v", err)
343 }
344 return id
345 }
346
347 func insertOrgAvatarOrg(t *testing.T, db orgsdb.DBTX, userID int64, slug string) int64 {
348 t.Helper()
349 var orgID int64
350 if err := db.QueryRow(context.Background(),
351 `INSERT INTO orgs (slug, display_name, created_by_user_id)
352 VALUES ($1, $2, $3)
353 RETURNING id`,
354 slug, slug, userID,
355 ).Scan(&orgID); err != nil {
356 t.Fatalf("insert org: %v", err)
357 }
358 if _, err := db.Exec(context.Background(),
359 `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'owner')`,
360 orgID, userID,
361 ); err != nil {
362 t.Fatalf("insert org member: %v", err)
363 }
364 return orgID
365 }
366