api: add user-resolution middleware and POST /api/setup

resolveUser attaches the session's provisioned geniusrun user (if any) to
request context without blocking; requireProvisionedUser (wired fully in
Task 13) 403s routes that need one. GET /api/session/me now reports
has_profile/display_name so the frontend can show the setup screen.
This commit is contained in:
2026-07-25 17:55:13 +02:00
parent 9140a00da7
commit 4dceb03fc0
6 changed files with 255 additions and 3 deletions

View File

@@ -54,6 +54,9 @@ func (s *Server) Router() http.Handler {
r.Get("/session/me", s.handleSessionMe) r.Get("/session/me", s.handleSessionMe)
r.Post("/session/logout", s.handleSessionLogout) r.Post("/session/logout", s.handleSessionLogout)
r.Use(s.resolveUser)
r.Post("/setup", s.handleSetup)
r.Route("/profile", func(r chi.Router) { r.Route("/profile", func(r chi.Router) {
r.Get("/", s.handleGetProfile) r.Get("/", s.handleGetProfile)
r.Put("/", s.handleUpdateProfile) r.Put("/", s.handleUpdateProfile)

View File

@@ -26,6 +26,8 @@ type SessionConfig struct {
type sessionMeResponse struct { type sessionMeResponse struct {
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
HasProfile bool `json:"has_profile"`
DisplayName string `json:"display_name,omitempty"`
} }
func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
@@ -92,5 +94,10 @@ func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusUnauthorized, "not authenticated") writeError(w, http.StatusUnauthorized, "not authenticated")
return return
} }
writeJSON(w, http.StatusOK, sessionMeResponse{Name: claims.Name, Email: claims.Email}) resp := sessionMeResponse{Name: claims.Name, Email: claims.Email}
if u, found := userFromContext(r.Context()); found {
resp.HasProfile = true
resp.DisplayName = u.DisplayName
}
writeJSON(w, http.StatusOK, resp)
} }

View File

@@ -0,0 +1,39 @@
package api
import (
"encoding/json"
"net/http"
"geniusrun/backend/internal/auth"
)
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
if _, found := userFromContext(r.Context()); found {
writeError(w, http.StatusConflict, "profile already exists for this account")
return
}
var body struct {
DisplayName string `json:"display_name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.DisplayName == "" {
writeError(w, http.StatusBadRequest, "display_name is required")
return
}
userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
}

View File

@@ -0,0 +1,65 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) {
s, db := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var me sessionMeResponse
unmarshalBody(t, rec, &me)
if me.HasProfile {
t.Fatal("expected a brand-new session to have no profile yet")
}
rec = doJSON(t, router, http.MethodPost, "/api/setup", map[string]any{"display_name": "Lucie"})
if rec.Code != http.StatusOK {
t.Fatalf("setup status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
unmarshalBody(t, rec, &me)
if !me.HasProfile || me.DisplayName != "Lucie" {
t.Fatalf("session/me after setup = %+v, want HasProfile=true DisplayName=Lucie", me)
}
u, found, err := db.GetUserBySub(newCtx(), "test-user") // doJSON's test cookie's Sub
if err != nil || !found {
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
}
if u.DisplayName != "Lucie" {
t.Errorf("stored DisplayName = %q, want Lucie", u.DisplayName)
}
}
func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
s, _ := newTestServer(t)
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": ""})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) {
s, db := newTestServer(t)
if _, err := db.ProvisionUser(newCtx(), "test-user", "Already Here"); err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"})
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
}
func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
t.Helper()
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err)
}
}

View File

@@ -0,0 +1,81 @@
package api
import (
"context"
"net/http"
"geniusrun/backend/internal/auth"
)
type userContextKey int
const resolvedUserContextKey userContextKey = iota
// resolvedUser is the geniusrun account (if any) bound to the current
// session's OIDC subject.
type resolvedUser struct {
ID int64
DisplayName string
}
// resolveUser runs after auth.RequireSession on every request and looks up
// whether the session's OIDC subject has a provisioned geniusrun user. It
// never blocks the request itself -- it only attaches the result (found or
// not) to context -- since a couple of routes (session/me, setup) must stay
// reachable for an authorized-but-not-yet-provisioned session. Routes that
// require a provisioned user are wrapped in requireProvisionedUser as well.
func (s *Server) resolveUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
// Unreachable in practice -- RequireSession already 401s before
// this middleware runs -- but fail closed rather than panic.
next.ServeHTTP(w, r)
return
}
u, found, err := s.DB.GetUserBySub(r.Context(), claims.Sub)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
ctx := r.Context()
if found {
ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, DisplayName: u.DisplayName})
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// requireProvisionedUser wraps routes that operate on a user's data: it
// 403s if the session's OIDC identity has no provisioned geniusrun user yet
// (resolveUser must run earlier in the chain). This -- not any
// client-supplied id -- is the only source of truth for "which user's data"
// a request may touch.
func requireProvisionedUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := userFromContext(r.Context()); !ok {
writeError(w, http.StatusForbidden, "no profile provisioned for this account yet")
return
}
next.ServeHTTP(w, r)
})
}
// userFromContext returns the resolved user for the current session, as
// populated by resolveUser.
func userFromContext(ctx context.Context) (resolvedUser, bool) {
u, ok := ctx.Value(resolvedUserContextKey).(resolvedUser)
return u, ok
}
// userIDFromContext is a convenience for the overwhelming majority of
// handlers, which only need the id. Panics if called somewhere
// requireProvisionedUser didn't already guarantee a resolved user -- that
// would be a routing bug, not a runtime condition to handle gracefully.
func userIDFromContext(ctx context.Context) int64 {
u, ok := userFromContext(ctx)
if !ok {
panic("api: userIDFromContext called without requireProvisionedUser in the middleware chain")
}
return u.ID
}

View File

@@ -0,0 +1,57 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"geniusrun/backend/internal/auth"
)
func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
s, db := newTestServer(t)
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
var gotUserID int64
var gotOK bool
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, ok := userFromContext(r.Context())
gotUserID, gotOK = u.ID, ok
})))
rec := doJSON(t, handler, http.MethodGet, "/", nil) // doJSON's test cookie carries Sub: "test-user"
_ = rec
if !gotOK || gotUserID != userID {
t.Fatalf("resolveUser: ok=%v userID=%d, want ok=true userID=%d", gotOK, gotUserID, userID)
}
}
func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(t *testing.T) {
s, _ := newTestServer(t)
var gotOK bool
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, gotOK = userFromContext(r.Context())
})))
doJSON(t, handler, http.MethodGet, "/", nil) // "test-user" sub, never provisioned in this test
if gotOK {
t.Fatal("expected userFromContext to report not-found for an unprovisioned sub")
}
}
func TestRequireProvisionedUser_RejectsWhenNoUserResolved(t *testing.T) {
handler := requireProvisionedUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("handler should not be reached")
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
}