go-bananas/internal/httpapi/preferences_test.go
Daniel Noulet 951dd8d26b
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
initial commit
2026-10-07 15:11:35 +02:00

232 lines
7.9 KiB
Go

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())
}
}