//go:build integration package httpx import ( "bytes" "encoding/base64" "encoding/json" "io" "log/slog" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "os" "testing" "time" "bountyboard/internal/auth" "bountyboard/internal/config" "bountyboard/internal/domain" "bountyboard/internal/files" "bountyboard/internal/metrics" "bountyboard/internal/store" "bountyboard/internal/testutil" ) // newAuthStack builds a full server backed by a per-run Mongo database. // extraEnv is applied before config.Load. func newAuthStack(t *testing.T, extraEnv map[string]string) (*httptest.Server, *Server, *store.Store, *config.Config) { t.Helper() db := testutil.MongoDB(t) if err := store.EnsureIndexes(t.Context(), db); err != nil { t.Fatal(err) } st := &store.Store{Client: db.Client(), DB: db} t.Setenv("SESSION_SECRET", "0123456789abcdef0123456789abcdef") t.Setenv("CREDENTIALS_ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32))) for k, v := range extraEnv { t.Setenv(k, v) } cfg, err := config.Load() if err != nil { t.Fatal(err) } logOut := io.Writer(io.Discard) if os.Getenv("TEST_LOG") == "1" { logOut = os.Stderr } srv := New(cfg, slog.New(slog.NewTextHandler(logOut, nil)), metrics.NewRegistry(), st, files.NewStore(db, cfg.MaxUploadMB)) ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) return ts, srv, st, cfg } // client returns an http client with a cookie jar that does not follow // redirects (so OIDC hops can be inspected). func newClient(t *testing.T) *http.Client { t.Helper() jar, err := cookiejar.New(nil) if err != nil { t.Fatal(err) } return &http.Client{ Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } } func postJSON(t *testing.T, c *http.Client, rawURL string, body any, csrf string) *http.Response { t.Helper() b, err := json.Marshal(body) if err != nil { t.Fatal(err) } req, err := http.NewRequest(http.MethodPost, rawURL, bytes.NewReader(b)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") if csrf != "" { req.Header.Set(csrfHeader, csrf) } resp, err := c.Do(req) if err != nil { t.Fatal(err) } return resp } func bodyJSON(t *testing.T, resp *http.Response, dst any) { t.Helper() defer resp.Body.Close() if err := json.NewDecoder(resp.Body).Decode(dst); err != nil { t.Fatalf("decode response: %v", err) } } func csrfFrom(t *testing.T, c *http.Client, baseURL string) string { t.Helper() u, _ := url.Parse(baseURL) for _, ck := range c.Jar.Cookies(u) { if ck.Name == csrfCookie { return ck.Value } } return "" } func errCode(t *testing.T, resp *http.Response) string { t.Helper() var env struct { Error struct { Code string `json:"code"` } `json:"error"` } bodyJSON(t, resp, &env) return env.Error.Code } func TestRegisterLoginLogoutFlow(t *testing.T) { ts, _, _, _ := newAuthStack(t, nil) c := newClient(t) // invalid inputs for _, tc := range []struct { body map[string]string want string }{ {map[string]string{"email": "not-an-email", "name": "X", "password": "longenough"}, "invalid_email"}, {map[string]string{"email": "a@b.co", "name": "", "password": "longenough"}, "invalid_name"}, {map[string]string{"email": "a@b.co", "name": "X", "password": "short"}, "weak_password"}, } { resp := postJSON(t, c, ts.URL+"/api/v1/auth/register", tc.body, "") if resp.StatusCode != http.StatusBadRequest { t.Fatalf("register %v: code %d", tc.body, resp.StatusCode) } if got := errCode(t, resp); got != tc.want { t.Errorf("error code %q, want %q", got, tc.want) } } // successful registration creates a developer and a session resp := postJSON(t, c, ts.URL+"/api/v1/auth/register", map[string]string{"email": "Dev@Example.com", "name": "Dev One", "password": "password123"}, "") if resp.StatusCode != http.StatusCreated { t.Fatalf("register: %d", resp.StatusCode) } var reg struct { User domain.User `json:"user"` } bodyJSON(t, resp, ®) if reg.User.Email != "dev@example.com" { t.Errorf("email not normalized: %q", reg.User.Email) } if !reg.User.Roles.Developer || reg.User.Roles.Admin || reg.User.Roles.Consultant { t.Errorf("self-registration roles wrong: %+v", reg.User.Roles) } // session works meResp, err := c.Get(ts.URL + "/api/v1/auth/me") if err != nil || meResp.StatusCode != http.StatusOK { t.Fatalf("me after register: %v %d", err, meResp.StatusCode) } meResp.Body.Close() // duplicate email resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/register", map[string]string{"email": "dev@example.com", "name": "Imposter", "password": "password123"}, "") if resp.StatusCode != http.StatusConflict { t.Fatalf("duplicate register: %d", resp.StatusCode) } resp.Body.Close() // logout requires CSRF resp = postJSON(t, c, ts.URL+"/api/v1/auth/logout", map[string]string{}, "") if resp.StatusCode != http.StatusForbidden { t.Fatalf("logout without csrf: %d", resp.StatusCode) } resp.Body.Close() csrf := csrfFrom(t, c, ts.URL) if csrf == "" { t.Fatal("csrf cookie not set") } resp = postJSON(t, c, ts.URL+"/api/v1/auth/logout", map[string]string{}, csrf) if resp.StatusCode != http.StatusNoContent { t.Fatalf("logout: %d", resp.StatusCode) } resp.Body.Close() meResp, err = c.Get(ts.URL + "/api/v1/auth/me") if err != nil || meResp.StatusCode != http.StatusUnauthorized { t.Fatalf("me after logout: %v %d", err, meResp.StatusCode) } meResp.Body.Close() // fresh login c2 := newClient(t) resp = postJSON(t, c2, ts.URL+"/api/v1/auth/login", map[string]string{"email": "dev@example.com", "password": "password123"}, "") if resp.StatusCode != http.StatusOK { t.Fatalf("login: %d", resp.StatusCode) } resp.Body.Close() // wrong password and unknown email are indistinguishable 401s for _, body := range []map[string]string{ {"email": "dev@example.com", "password": "wrong-password"}, {"email": "ghost@example.com", "password": "password123"}, } { resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", body, "") if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("login %v: %d", body, resp.StatusCode) } if got := errCode(t, resp); got != "invalid_credentials" { t.Errorf("error code %q", got) } } } func TestLoginRateLimit(t *testing.T) { ts, _, _, _ := newAuthStack(t, nil) for i := 0; i < 10; i++ { resp := postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", map[string]string{"email": "victim@example.com", "password": "guess"}, "") if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("attempt %d: %d", i+1, resp.StatusCode) } resp.Body.Close() } resp := postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", map[string]string{"email": "victim@example.com", "password": "guess"}, "") if resp.StatusCode != http.StatusTooManyRequests { t.Fatalf("11th attempt: %d, want 429", resp.StatusCode) } resp.Body.Close() // other accounts from the same IP are unaffected (key includes email) resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", map[string]string{"email": "other@example.com", "password": "guess"}, "") if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("different email after limit: %d", resp.StatusCode) } resp.Body.Close() } func TestDisabledAccount(t *testing.T) { ts, _, st, _ := newAuthStack(t, nil) c := newClient(t) resp := postJSON(t, c, ts.URL+"/api/v1/auth/register", map[string]string{"email": "d@example.com", "name": "D", "password": "password123"}, "") var reg struct { User domain.User `json:"user"` } bodyJSON(t, resp, ®) // disable directly in the store if _, err := st.DB.Collection("users").UpdateOne(t.Context(), map[string]any{"_id": reg.User.ID}, map[string]any{"$set": map[string]any{"disabled": true}}); err != nil { t.Fatal(err) } // existing session is revoked on next use meResp, _ := c.Get(ts.URL + "/api/v1/auth/me") if meResp.StatusCode != http.StatusForbidden { t.Fatalf("me as disabled: %d", meResp.StatusCode) } meResp.Body.Close() // fresh login rejected resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", map[string]string{"email": "d@example.com", "password": "password123"}, "") if resp.StatusCode != http.StatusForbidden { t.Fatalf("login as disabled: %d", resp.StatusCode) } if got := errCode(t, resp); got != "account_disabled" { t.Errorf("error code %q", got) } } func TestBootstrapAdminAndForcedPasswordChange(t *testing.T) { ts, srv, st, cfg := newAuthStack(t, map[string]string{ "ADMIN_EMAIL": "root@example.com", "ADMIN_INITIAL_PASSWORD": "initial-password", }) if err := auth.EnsureBootstrapAdmin(t.Context(), st, cfg, srv.log); err != nil { t.Fatal(err) } // idempotent if err := auth.EnsureBootstrapAdmin(t.Context(), st, cfg, srv.log); err != nil { t.Fatal(err) } if n, _ := st.CountUsers(t.Context()); n != 1 { t.Fatalf("user count = %d, want 1", n) } c := newClient(t) resp := postJSON(t, c, ts.URL+"/api/v1/auth/login", map[string]string{"email": "root@example.com", "password": "initial-password"}, "") if resp.StatusCode != http.StatusOK { t.Fatalf("admin login: %d", resp.StatusCode) } var login struct { User domain.User `json:"user"` MustChangePassword bool `json:"mustChangePassword"` } bodyJSON(t, resp, &login) if !login.User.Roles.Admin || !login.MustChangePassword { t.Fatalf("bootstrap admin login: %+v must=%v", login.User.Roles, login.MustChangePassword) } // while mustChange is set, non-allowlisted endpoints are blocked csrf := csrfFrom(t, c, ts.URL) probe := httptest.NewServer(srv.requireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))) defer probe.Close() req, _ := http.NewRequest(http.MethodGet, probe.URL+"/api/v1/anything", nil) u, _ := url.Parse(ts.URL) for _, ck := range c.Jar.Cookies(u) { req.AddCookie(ck) } pResp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } if pResp.StatusCode != http.StatusForbidden { t.Fatalf("protected endpoint under mustChange: %d, want 403", pResp.StatusCode) } pResp.Body.Close() // wrong current password resp = postJSON(t, c, ts.URL+"/api/v1/auth/change-password", map[string]string{"currentPassword": "nope", "newPassword": "fresh-password-1"}, csrf) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("change with wrong current: %d", resp.StatusCode) } resp.Body.Close() // correct change clears the flag resp = postJSON(t, c, ts.URL+"/api/v1/auth/change-password", map[string]string{"currentPassword": "initial-password", "newPassword": "fresh-password-1"}, csrf) if resp.StatusCode != http.StatusNoContent { t.Fatalf("change password: %d", resp.StatusCode) } resp.Body.Close() req, _ = http.NewRequest(http.MethodGet, probe.URL+"/api/v1/anything", nil) for _, ck := range c.Jar.Cookies(u) { req.AddCookie(ck) } pResp, err = http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } if pResp.StatusCode != http.StatusOK { t.Fatalf("protected endpoint after change: %d", pResp.StatusCode) } pResp.Body.Close() // old password dead, new one works resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", map[string]string{"email": "root@example.com", "password": "initial-password"}, "") if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("old password still works: %d", resp.StatusCode) } resp.Body.Close() resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", map[string]string{"email": "root@example.com", "password": "fresh-password-1"}, "") if resp.StatusCode != http.StatusOK { t.Fatalf("new password rejected: %d", resp.StatusCode) } resp.Body.Close() } func TestPasswordChangeRevokesOtherSessions(t *testing.T) { ts, _, _, _ := newAuthStack(t, nil) c1, c2 := newClient(t), newClient(t) resp := postJSON(t, c1, ts.URL+"/api/v1/auth/register", map[string]string{"email": "u@example.com", "name": "U", "password": "password123"}, "") resp.Body.Close() resp = postJSON(t, c2, ts.URL+"/api/v1/auth/login", map[string]string{"email": "u@example.com", "password": "password123"}, "") resp.Body.Close() resp = postJSON(t, c1, ts.URL+"/api/v1/auth/change-password", map[string]string{"currentPassword": "password123", "newPassword": "password456"}, csrfFrom(t, c1, ts.URL)) if resp.StatusCode != http.StatusNoContent { t.Fatalf("change: %d", resp.StatusCode) } resp.Body.Close() // c1 (the changer) still works, c2 is revoked r1, _ := c1.Get(ts.URL + "/api/v1/auth/me") if r1.StatusCode != http.StatusOK { t.Errorf("changer session: %d", r1.StatusCode) } r1.Body.Close() r2, _ := c2.Get(ts.URL + "/api/v1/auth/me") if r2.StatusCode != http.StatusUnauthorized { t.Errorf("other session after password change: %d, want 401", r2.StatusCode) } r2.Body.Close() } func TestExpiredSessionRejected(t *testing.T) { ts, _, st, _ := newAuthStack(t, nil) c := newClient(t) resp := postJSON(t, c, ts.URL+"/api/v1/auth/register", map[string]string{"email": "e@example.com", "name": "E", "password": "password123"}, "") resp.Body.Close() // force-expire every session for the user if _, err := st.DB.Collection("sessions").UpdateMany(t.Context(), map[string]any{}, map[string]any{"$set": map[string]any{"expiresAt": time.Now().Add(-time.Minute)}}); err != nil { t.Fatal(err) } meResp, _ := c.Get(ts.URL + "/api/v1/auth/me") if meResp.StatusCode != http.StatusUnauthorized { t.Fatalf("expired session: %d", meResp.StatusCode) } meResp.Body.Close() } func TestLogoutAll(t *testing.T) { ts, _, _, _ := newAuthStack(t, nil) c1, c2 := newClient(t), newClient(t) resp := postJSON(t, c1, ts.URL+"/api/v1/auth/register", map[string]string{"email": "la@example.com", "name": "LA", "password": "password123"}, "") resp.Body.Close() resp = postJSON(t, c2, ts.URL+"/api/v1/auth/login", map[string]string{"email": "la@example.com", "password": "password123"}, "") resp.Body.Close() resp = postJSON(t, c1, ts.URL+"/api/v1/auth/logout-all", map[string]string{}, csrfFrom(t, c1, ts.URL)) if resp.StatusCode != http.StatusOK { t.Fatalf("logout-all: %d", resp.StatusCode) } var out struct { Revoked int `json:"revoked"` } bodyJSON(t, resp, &out) if out.Revoked != 2 { t.Errorf("revoked = %d, want 2", out.Revoked) } for i, c := range []*http.Client{c1, c2} { r, _ := c.Get(ts.URL + "/api/v1/auth/me") if r.StatusCode != http.StatusUnauthorized { t.Errorf("client %d after logout-all: %d", i+1, r.StatusCode) } r.Body.Close() } } func TestOIDCOnlyAccountCannotPasswordLogin(t *testing.T) { ts, _, st, _ := newAuthStack(t, nil) if err := st.CreateUser(t.Context(), &domain.User{ ID: "01JOIDCONLY0000000000000US", Email: "sso@example.com", Name: "SSO", Roles: domain.Roles{Developer: true}, Auth: domain.Auth{OIDC: &domain.OIDCAuth{Issuer: "https://idp", Subject: "x"}}, }); err != nil { t.Fatal(err) } resp := postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", map[string]string{"email": "sso@example.com", "password": "whatever123"}, "") if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("oidc-only password login: %d", resp.StatusCode) } resp.Body.Close() }