package httpapi_test import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/urpi/go-bananas/internal/auth" "github.com/urpi/go-bananas/internal/config" "github.com/urpi/go-bananas/internal/domain" "github.com/urpi/go-bananas/internal/httpapi" "github.com/urpi/go-bananas/internal/logging" "github.com/urpi/go-bananas/internal/store" ) // devUser is the account the dev bypass authenticates as. Dev mode is the only // fixture available without minting a real session cookie: Authenticate reads the // session or the dev bypass and never consults the login resolver, so a stub // resolver would simply never run. const devUser = "dev-user=person@example.test" // withDevUser appends the dev bypass parameter to a path. func withDevUser(path string) string { separator := "?" if strings.Contains(path, "?") { separator = "&" } return path + separator + devUser } // preferenceRouter builds a router that authenticates every caller through the dev // bypass, against a real store so the migration runs. func preferenceRouter(t *testing.T) (http.Handler, *store.Store) { t.Helper() st, err := store.Open(context.Background(), store.Options{Path: ":memory:"}) if err != nil { t.Fatalf("open store: %v", err) } t.Cleanup(func() { _ = st.Close() }) cfg := testConfig(t) cfg.AuthMode = config.AuthModeDev cfg.DevFallbackRole = string(domain.RoleViewer) cfg.SessionSecure = false authenticator, err := auth.New(cfg, nil, nil) if err != nil { t.Fatalf("new authenticator: %v", err) } return httpapi.NewRouter(httpapi.Deps{ Config: cfg, Store: st, Cache: httpapi.NewThemeCache(), Authenticator: authenticator, Log: logging.New(logging.Options{Level: "error", Writer: io.Discard}), Version: "test", RuntimeDefaults: func() (string, domain.Appearance) { return "", "" }, }), st } func putJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder { t.Helper() encoded, err := json.Marshal(body) if err != nil { t.Fatalf("encode body: %v", err) } rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodPut, path, bytes.NewReader(encoded))) return rec } func deleteRequest(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodDelete, path, nil)) return rec } // seedPublishedTheme inserts a published theme, since a preference may only name // one that runtime consumers can load. func seedPublishedTheme(t *testing.T, st *store.Store, slug string) { t.Helper() ctx := context.Background() theme := &domain.Theme{ Slug: slug, Name: slug, Status: domain.StatusDraft, DefaultAppearance: domain.AppearanceLight, Tokens: domain.TokenDoc{}, } if err := st.Create(ctx, theme, ""); err != nil { t.Fatalf("create theme: %v", err) } published := domain.StatusPublished if _, err := st.Update(ctx, slug, store.UpdateFields{Status: &published}); err != nil { t.Fatalf("publish theme: %v", err) } } func decodeBody[T any](t *testing.T, rec *httptest.ResponseRecorder) T { t.Helper() var value T if err := json.Unmarshal(rec.Body.Bytes(), &value); err != nil { t.Fatalf("decode %q: %v", rec.Body.String(), err) } return value } func TestPreferencesEndpointRoundTrip(t *testing.T) { router, st := preferenceRouter(t) seedPublishedTheme(t, st, "midnight") // An account that has never chosen reads as empty. got := decodeBody[domain.UserPreferences](t, get(t, router, withDevUser("/api/v1/me/preferences"))) if got.SelectedTheme != "" { t.Fatalf("initial = %+v, want empty", got) } rec := putJSON(t, router, withDevUser("/api/v1/me/preferences"), domain.UserPreferences{ SelectedTheme: "midnight", SelectedAppearance: domain.AppearanceDark, }) if rec.Code != http.StatusOK { t.Fatalf("put = %d, want 200: %s", rec.Code, rec.Body.String()) } if echoed := decodeBody[domain.UserPreferences](t, rec); echoed.SelectedTheme != "midnight" || echoed.SelectedAppearance != domain.AppearanceDark { t.Fatalf("echoed = %+v", echoed) } got = decodeBody[domain.UserPreferences](t, get(t, router, withDevUser("/api/v1/me/preferences"))) if got.SelectedTheme != "midnight" || got.SelectedAppearance != domain.AppearanceDark { t.Fatalf("read back = %+v", got) } if rec := deleteRequest(t, router, withDevUser("/api/v1/me/preferences")); rec.Code != http.StatusOK { t.Fatalf("delete = %d, want 200: %s", rec.Code, rec.Body.String()) } got = decodeBody[domain.UserPreferences](t, get(t, router, withDevUser("/api/v1/me/preferences"))) if got.SelectedTheme != "" { t.Fatalf("after reset = %+v, want empty", got) } } func TestPreferencesRejectAnUnusableTheme(t *testing.T) { router, st := preferenceRouter(t) seedPublishedTheme(t, st, "midnight") for name, body := range map[string]domain.UserPreferences{ "unknown theme": {SelectedTheme: "no-such-theme"}, "bad slug": {SelectedTheme: "Not A Slug"}, "bad appearance": { SelectedTheme: "midnight", SelectedAppearance: "sepia", }, } { t.Run(name, func(t *testing.T) { rec := putJSON(t, router, withDevUser("/api/v1/me/preferences"), body) // A malformed preference is the caller's mistake, so a 400 rather than // the 500 an unwrapped validation error would produce. if rec.Code != http.StatusBadRequest { t.Fatalf("put = %d, want 400: %s", rec.Code, rec.Body.String()) } }) } } // TestSessionCarriesPreferences is what lets the UI preselect a theme from the same // round trip that establishes who the caller is. func TestSessionCarriesPreferences(t *testing.T) { router, st := preferenceRouter(t) seedPublishedTheme(t, st, "midnight") if rec := putJSON(t, router, withDevUser("/api/v1/me/preferences"), domain.UserPreferences{ SelectedTheme: "midnight", SelectedAppearance: domain.AppearanceLight, }); rec.Code != http.StatusOK { t.Fatalf("put = %d: %s", rec.Code, rec.Body.String()) } rec := get(t, router, withDevUser("/api/v1/auth/me")) if rec.Code != http.StatusOK { t.Fatalf("me = %d: %s", rec.Code, rec.Body.String()) } var payload struct { Authenticated bool `json:"authenticated"` Preferences domain.UserPreferences `json:"preferences"` Identity *domain.Identity `json:"identity"` Extra map[string]json.RawMessage `json:"-"` } if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode session: %v", err) } if !payload.Authenticated || payload.Identity == nil { t.Fatalf("session = %+v, want an authenticated caller", payload) } if payload.Preferences.SelectedTheme != "midnight" || payload.Preferences.SelectedAppearance != domain.AppearanceLight { t.Fatalf("session preferences = %+v", payload.Preferences) } } // TestAnonymousSessionOmitsPreferences keeps the unauthenticated response free of // anything about a person, since that endpoint is reachable without a session. func TestAnonymousSessionOmitsPreferences(t *testing.T) { router := newUnauthenticatedRouter(t) rec := get(t, router, withDevUser("/api/v1/auth/me")) if rec.Code != http.StatusOK { t.Fatalf("me = %d: %s", rec.Code, rec.Body.String()) } var payload map[string]json.RawMessage if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode session: %v", err) } if _, present := payload["preferences"]; present { t.Error("an anonymous session must not carry preferences") } } func TestPreferencesRequireASession(t *testing.T) { router := newUnauthenticatedRouter(t) if rec := get(t, router, withDevUser("/api/v1/me/preferences")); rec.Code != http.StatusUnauthorized { t.Fatalf("get = %d, want 401", rec.Code) } rec := putJSON(t, router, withDevUser("/api/v1/me/preferences"), domain.UserPreferences{}) if rec.Code != http.StatusUnauthorized { t.Fatalf("put = %d, want 401: %s", rec.Code, rec.Body.String()) } }