//go:build integration package httpx import ( "bytes" "encoding/json" "io" "mime/multipart" "net/http" "strings" "testing" "time" "go.mongodb.org/mongo-driver/v2/bson" "bountyboard/internal/domain" "bountyboard/internal/files" ) // tiny valid 1x1 PNG var pngBytes = []byte{ 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0x0D, 'I', 'H', 'D', 'R', 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 0x1F, 0x15, 0xC4, 0x89, 0, 0, 0, 0x0A, 'I', 'D', 'A', 'T', 0x78, 0x9C, 0x63, 0, 1, 0, 0, 5, 0, 1, 0x0D, 0x0A, 0x2D, 0xB4, 0, 0, 0, 0, 'I', 'E', 'N', 'D', 0xAE, 0x42, 0x60, 0x82, } func registerUser(t *testing.T, ts string, c *http.Client, email, name string) domain.User { t.Helper() resp := postJSON(t, c, ts+"/api/v1/auth/register", map[string]string{"email": email, "name": name, "password": "password123"}, "") if resp.StatusCode != http.StatusCreated { t.Fatalf("register %s: %d", email, resp.StatusCode) } var out struct { User domain.User `json:"user"` } bodyJSON(t, resp, &out) return out.User } func patchJSON(t *testing.T, c *http.Client, rawURL string, body any, csrf string) *http.Response { t.Helper() b, _ := json.Marshal(body) req, _ := http.NewRequest(http.MethodPatch, rawURL, bytes.NewReader(b)) req.Header.Set("Content-Type", "application/json") req.Header.Set(csrfHeader, csrf) resp, err := c.Do(req) if err != nil { t.Fatal(err) } return resp } func TestProfilePatchAndThemePersistence(t *testing.T) { ts, _, _, _ := newAuthStack(t, nil) c := newClient(t) u := registerUser(t, ts.URL, c, "p@example.com", "P User") csrf := csrfFrom(t, c, ts.URL) resp := patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{ "version": u.Version, "name": "Updated Name", "bio": "I write Go.", "contact": map[string]any{"phone": "+1 555 0100", "location": "Berlin", "links": []string{"https://example.com"}}, "extra": map[string]any{"GitHub": "puser", "Timezone": "CET"}, "settings": map[string]any{ "theme": "dark", }, }, csrf) if resp.StatusCode != http.StatusOK { t.Fatalf("patch: %d", resp.StatusCode) } var out struct { User domain.User `json:"user"` } bodyJSON(t, resp, &out) if out.User.Name != "Updated Name" || out.User.Settings.Theme != "dark" || out.User.Contact.Location != "Berlin" || out.User.Extra["GitHub"] != "puser" { t.Fatalf("patched user wrong: %+v", out.User) } if out.User.Version != u.Version+1 { t.Errorf("version = %d, want %d", out.User.Version, u.Version+1) } // stale version → 409 resp = patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{ "version": u.Version, "name": "Stale Write", }, csrf) if resp.StatusCode != http.StatusConflict { t.Fatalf("stale patch: %d, want 409", resp.StatusCode) } resp.Body.Close() // theme-only update without version (nav toggle path) resp = patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{ "settings": map[string]any{"theme": "light"}, }, csrf) if resp.StatusCode != http.StatusOK { t.Fatalf("theme toggle patch: %d", resp.StatusCode) } bodyJSON(t, resp, &out) if out.User.Settings.Theme != "light" { t.Errorf("theme = %q", out.User.Settings.Theme) } // invalid theme rejected resp = patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{ "settings": map[string]any{"theme": "rainbow-unicorn"}, }, csrf) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("invalid theme: %d", resp.StatusCode) } resp.Body.Close() } func uploadAvatar(t *testing.T, c *http.Client, ts, csrf, filename string, content []byte) *http.Response { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) fw, err := mw.CreateFormFile("file", filename) if err != nil { t.Fatal(err) } if _, err := fw.Write(content); err != nil { t.Fatal(err) } mw.Close() req, _ := http.NewRequest(http.MethodPost, ts+"/api/v1/profile/avatar", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set(csrfHeader, csrf) resp, err := c.Do(req) if err != nil { t.Fatal(err) } return resp } func TestAvatarUploadAndFileServing(t *testing.T) { ts, _, _, cfg := newAuthStack(t, nil) c := newClient(t) registerUser(t, ts.URL, c, "av@example.com", "Av User") csrf := csrfFrom(t, c, ts.URL) // non-image rejected resp := uploadAvatar(t, c, ts.URL, csrf, "notes.txt", []byte("just text, definitely not an image")) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("text avatar: %d, want 400", resp.StatusCode) } resp.Body.Close() // PNG accepted resp = uploadAvatar(t, c, ts.URL, csrf, "me.png", pngBytes) if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) t.Fatalf("png avatar: %d %s", resp.StatusCode, b) } var up struct { FileID string `json:"fileId"` } bodyJSON(t, resp, &up) // served with session fResp, err := c.Get(ts.URL + "/files/" + up.FileID) if err != nil { t.Fatal(err) } got, _ := io.ReadAll(fResp.Body) fResp.Body.Close() if fResp.StatusCode != http.StatusOK || !bytes.Equal(got, pngBytes) { t.Fatalf("file fetch: %d, %d bytes", fResp.StatusCode, len(got)) } if ct := fResp.Header.Get("Content-Type"); ct != "image/png" { t.Errorf("content type %q", ct) } // anonymous fetch rejected aResp, err := http.Get(ts.URL + "/files/" + up.FileID) if err != nil { t.Fatal(err) } aResp.Body.Close() if aResp.StatusCode != http.StatusUnauthorized { t.Fatalf("anonymous file fetch: %d, want 401", aResp.StatusCode) } // signed token works without a session and expires (§5.1 acceptance) goodURL := files.SignedURL(ts.URL, []byte(cfg.SessionSecret), up.FileID, time.Now().Add(time.Hour)) gResp, err := http.Get(goodURL) if err != nil { t.Fatal(err) } gResp.Body.Close() if gResp.StatusCode != http.StatusOK { t.Fatalf("signed fetch: %d", gResp.StatusCode) } expiredURL := files.SignedURL(ts.URL, []byte(cfg.SessionSecret), up.FileID, time.Now().Add(-time.Minute)) eResp, err := http.Get(expiredURL) if err != nil { t.Fatal(err) } eResp.Body.Close() if eResp.StatusCode != http.StatusUnauthorized { t.Fatalf("expired signed fetch: %d, want 401", eResp.StatusCode) } // replacing the avatar deletes the old file resp = uploadAvatar(t, c, ts.URL, csrf, "me2.png", pngBytes) var up2 struct { FileID string `json:"fileId"` } bodyJSON(t, resp, &up2) oldResp, err := c.Get(ts.URL + "/files/" + up.FileID) if err != nil { t.Fatal(err) } oldResp.Body.Close() if oldResp.StatusCode != http.StatusNotFound { t.Fatalf("old avatar after replace: %d, want 404", oldResp.StatusCode) } } func TestNavigationByRole(t *testing.T) { ts, _, st, _ := newAuthStack(t, nil) c := newClient(t) u := registerUser(t, ts.URL, c, "nav@example.com", "Nav User") fetchHome := func() string { resp, err := c.Get(ts.URL + "/") if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("home: %d", resp.StatusCode) } b, _ := io.ReadAll(resp.Body) return string(b) } // developer: board links, no admin/consultant links html := fetchHome() if !strings.Contains(html, `href="/board"`) { t.Error("developer nav missing bounty board") } for _, forbidden := range []string{`href="/admin"`, `href="/consultant/board"`} { if strings.Contains(html, forbidden) { t.Errorf("developer nav leaks %s", forbidden) } } // grant consultant+admin directly in the store if _, err := st.DB.Collection("users").UpdateOne(t.Context(), bson.M{"_id": u.ID}, bson.M{"$set": bson.M{"roles.admin": true, "roles.consultant": true}}); err != nil { t.Fatal(err) } html = fetchHome() for _, want := range []string{`href="/admin"`, `href="/consultant/board"`, `href="/consultant/reviews"`} { if !strings.Contains(html, want) { t.Errorf("admin+consultant nav missing %s", want) } } } func TestForcedPasswordChangeRedirectsPages(t *testing.T) { ts, _, st, _ := newAuthStack(t, nil) c := newClient(t) u := registerUser(t, ts.URL, c, "fc@example.com", "FC") if _, err := st.DB.Collection("users").UpdateOne(t.Context(), bson.M{"_id": u.ID}, bson.M{"$set": bson.M{"auth.local.mustChange": true}}); err != nil { t.Fatal(err) } resp, err := c.Get(ts.URL + "/profile") if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != "/change-password" { t.Fatalf("page under mustChange: %d → %q", resp.StatusCode, resp.Header.Get("Location")) } }