0d28f69320
- Themes: add neon (cyber gradient), terminal (green CRT), blueprint (graph paper) and sunset (vaporwave) alongside the default Light (Y2K paper) and Dark. Theme toggle now cycles all; profile selector lists them; server validates against a shared whitelist. - Bounty cards now show the project (customer) name. - Remove the celebratory emoji from the empty Review Queue. - Full-width h1 underline rule; My Tasks columns stack vertically for more room per task; card action buttons wrap/right-align so they never overflow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
287 lines
8.2 KiB
Go
287 lines
8.2 KiB
Go
//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"))
|
|
}
|
|
}
|