initial commit
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
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
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
232
internal/httpapi/preferences_test.go
Normal file
232
internal/httpapi/preferences_test.go
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
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())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue