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
232 lines
7.9 KiB
Go
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())
|
|
}
|
|
}
|