From 2adf41604fd050003c89e21d9afc8d83ed1951a6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 20 Apr 2026 22:11:02 -0400 Subject: [PATCH] feat(api): GET /api/me Returns the authenticated user (id/username/is_admin). Shape matches UserView used in the login response so SPA stores stay typed against one interface. Co-Authored-By: Claude Opus 4.7 --- internal/api/api.go | 8 -------- internal/api/me.go | 25 +++++++++++++++++++++++ internal/api/me_test.go | 44 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 internal/api/me.go create mode 100644 internal/api/me_test.go diff --git a/internal/api/api.go b/internal/api/api.go index 79ffbcea..f74ad3e4 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -6,7 +6,6 @@ package api import ( "log/slog" - "net/http" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -33,10 +32,3 @@ type handlers struct { pool *pgxpool.Pool logger *slog.Logger } - -// Stub handlers so Mount() compiles. Real implementations in later tasks -// replace these in place (Task 8 me). - -func (h *handlers) handleGetMe(w http.ResponseWriter, r *http.Request) { - writeErr(w, http.StatusNotImplemented, "not_implemented", "me not wired") -} diff --git a/internal/api/me.go b/internal/api/me.go new file mode 100644 index 00000000..0e63c372 --- /dev/null +++ b/internal/api/me.go @@ -0,0 +1,25 @@ +package api + +import ( + "encoding/json" + "net/http" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" +) + +func (h *handlers) handleGetMe(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + // Hitting /me without RequireUser in front of it is a routing bug; + // it can't happen in real traffic. + h.logger.Error("api: /me reached without authenticated user") + writeErr(w, http.StatusInternalServerError, "server_error", "missing auth context") + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(UserView{ + ID: user.ID, + Username: user.Username, + IsAdmin: user.IsAdmin, + }) +} diff --git a/internal/api/me_test.go b/internal/api/me_test.go new file mode 100644 index 00000000..1c582feb --- /dev/null +++ b/internal/api/me_test.go @@ -0,0 +1,44 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func TestHandleGetMe_ReturnsAuthenticatedUser(t *testing.T) { + h, pool := testHandlers(t) + user := seedUser(t, pool, "alice", "hunter2", true) + + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user)) + w := httptest.NewRecorder() + h.handleGetMe(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + var got UserView + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Username != "alice" || !got.IsAdmin { + t.Errorf("user = %+v, want alice/admin", got) + } +} + +func TestHandleGetMe_MissingContextReturns500(t *testing.T) { + h, _ := testHandlers(t) + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + w := httptest.NewRecorder() + h.handleGetMe(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500 (context should be populated by RequireUser)", w.Code) + } + _ = dbq.User{} // keep the import used +}