package server import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "testing/fstest" "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" ) const settingsPath = "/api/v1/settings/household" // signedIn returns a request carrying stubSessions' valid session cookie and // its CSRF token. func signedIn(method, target string, body io.Reader) *http.Request { req := httptest.NewRequest(method, target, body) req.AddCookie(validSessionCookie) req.Header.Set(CSRFHeader, testSession.CSRFToken) return req } func serve(t *testing.T, h http.Handler, req *http.Request) (*httptest.ResponseRecorder, map[string]any) { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Errorf("Content-Type = %q, want application/json", ct) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode body %q: %v", rec.Body.String(), err) } return rec, body } func TestGetHouseholdSettings(t *testing.T) { t.Parallel() rec, body := serve(t, newTestRouter(t, "dev"), signedIn(http.MethodGet, settingsPath, nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body) } want := map[string]any{ "timezone": "America/New_York", "dinner_time": "17:30", "reminder_time": "16:30", "review_time": "09:00", "plan_draft_dow": float64(5), "plan_draft_time": "09:00", "suggestions_dow": float64(0), "suggestions_time": "08:00", "chat_autosend_dow": float64(6), "chat_autosend_time": "08:00", "onboarding_completed_at": nil, "updates_seen_at": nil, "updated_by": nil, } for k, v := range want { got, ok := body[k] if !ok || got != v { t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v) } } for _, k := range []string{"created_at", "updated_at"} { if s, ok := body[k].(string); !ok || s == "" { t.Errorf("%s = %#v, want a timestamp", k, body[k]) } } if len(body) != len(want)+2 { t.Errorf("body has %d fields, want %d: %v", len(body), len(want)+2, body) } } func TestPatchHouseholdSettings(t *testing.T) { t.Parallel() settings := newTestSettings(t) router := newTestRouterWith(t, "dev", settings, newTestRunner(t)) var notified []household.Settings settings.Subscribe(func(s household.Settings) { notified = append(notified, s) }) rec, body := serve(t, router, signedIn(http.MethodPatch, settingsPath, strings.NewReader(`{"timezone":"Europe/Berlin","dinner_time":"18:00","plan_draft_dow":4}`))) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body) } for k, v := range map[string]any{ "timezone": "Europe/Berlin", "dinner_time": "18:00", "plan_draft_dow": float64(4), "reminder_time": "16:30", // unchanged "updated_by": testSession.Subject, } { if body[k] != v { t.Errorf("%s = %#v, want %#v", k, body[k], v) } } if len(notified) != 1 || notified[0].Timezone != "Europe/Berlin" { t.Errorf("subscriber notified with %+v, want one change to Europe/Berlin", notified) } _, body = serve(t, router, signedIn(http.MethodGet, settingsPath, nil)) if body["timezone"] != "Europe/Berlin" || body["updated_by"] != testSession.Subject { t.Errorf("GET after PATCH = %v", body) } } func TestPatchHouseholdSettingsRejected(t *testing.T) { t.Parallel() tests := []struct { name string body string code string in string // the error message must contain this }{ {"bad timezone", `{"timezone":"Mars/Olympus_Mons"}`, "invalid_settings", "timezone"}, {"bad time", `{"dinner_time":"25:00"}`, "invalid_settings", "dinner_time"}, {"unpadded time", `{"reminder_time":"9:30"}`, "invalid_settings", "reminder_time"}, {"bad dow", `{"suggestions_dow":7}`, "invalid_settings", "suggestions_dow"}, {"negative dow", `{"chat_autosend_dow":-1}`, "invalid_settings", "chat_autosend_dow"}, {"empty object", `{}`, "invalid_settings", "no settings to change"}, {"fractional dow", `{"plan_draft_dow":5.5}`, "invalid_request", "plan_draft_dow"}, {"string dow", `{"plan_draft_dow":"5"}`, "invalid_request", "plan_draft_dow"}, {"numeric time", `{"dinner_time":1730}`, "invalid_request", "dinner_time"}, {"null value", `{"timezone":null}`, "invalid_request", "timezone"}, {"unknown field", `{"dinner":"17:30"}`, "invalid_request", "dinner"}, {"read-only field", `{"updated_by":"someone"}`, "invalid_request", "updated_by"}, {"not an object", `["timezone"]`, "invalid_request", "JSON object"}, {"null body", `null`, "invalid_request", "JSON object"}, {"trailing data", `{"dinner_time":"18:00"} {}`, "invalid_request", "JSON object"}, {"malformed", `{"dinner_time":`, "invalid_request", "JSON object"}, {"too large", `{"timezone":"` + strings.Repeat("a", maxSettingsBody) + `"}`, "invalid_request", "exceeds"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() settings := newTestSettings(t) router := newTestRouterWith(t, "dev", settings, newTestRunner(t)) notified := 0 settings.Subscribe(func(household.Settings) { notified++ }) rec, body := serve(t, router, signedIn(http.MethodPatch, settingsPath, strings.NewReader(tt.body))) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body) } apiErr, ok := body["error"].(map[string]any) if !ok || len(body) != 1 || len(apiErr) != 3 { t.Fatalf("body = %s, want {\"error\":{code,message,retryable}}", rec.Body) } if apiErr["code"] != tt.code || apiErr["retryable"] != false { t.Errorf("error = %v, want code %q, retryable false", apiErr, tt.code) } if msg, _ := apiErr["message"].(string); !strings.Contains(msg, tt.in) { t.Errorf("message %q does not contain %q", msg, tt.in) } if notified != 0 { t.Errorf("rejected PATCH notified subscribers %d times", notified) } _, after := serve(t, router, signedIn(http.MethodGet, settingsPath, nil)) if after["timezone"] != "America/New_York" || after["dinner_time"] != "17:30" || after["updated_by"] != nil { t.Errorf("rejected PATCH changed settings: %v", after) } }) } } func TestHouseholdSettingsMethodNotAllowed(t *testing.T) { t.Parallel() rec, body := serve(t, newTestRouter(t, "dev"), signedIn(http.MethodPut, settingsPath, strings.NewReader(`{}`))) if rec.Code != http.StatusMethodNotAllowed { t.Errorf("status = %d, want 405", rec.Code) } if apiErr, _ := body["error"].(map[string]any); apiErr["code"] != "method_not_allowed" { t.Errorf("body = %v", body) } } // TestHouseholdSettingsWithRealSession drives the settings API through the // production router and session manager: a member signs in, reads the CSRF // token from GET /api/v1/me and PATCHes; updated_by comes from the session. // Without a session, or without the CSRF token, the PATCH is refused and // nothing changes. func TestHouseholdSettingsWithRealSession(t *testing.T) { t.Parallel() ctx := context.Background() st, err := store.Open(ctx, t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { if err := st.Close(); err != nil { t.Error(err) } }) if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { t.Fatal(err) } if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil { t.Fatal(err) } sessions, err := auth.NewSessionManager(st, time.Hour, 24*time.Hour) if err != nil { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } // Sign in as the callback would. login := httptest.NewRecorder() if err := sessions.StartSession(login, httptest.NewRequest(http.MethodGet, "/auth/callback", nil), auth.Claims{ Subject: "authentik-sub-42", Email: "cook@example.com", Name: "Home Cook", Groups: []string{"mealquest-admin"}, }); err != nil { t.Fatal(err) } cookies := login.Result().Cookies() if len(cookies) != 1 || cookies[0].Name != auth.SessionCookieName { t.Fatalf("StartSession set cookies %v, want the session cookie", cookies) } cookie := cookies[0] withCookie := func(req *http.Request) *http.Request { req.AddCookie(cookie) return req } rec, me := serve(t, router, withCookie(httptest.NewRequest(http.MethodGet, "/api/v1/me", nil))) token, _ := me["csrfToken"].(string) if rec.Code != http.StatusOK || token == "" { t.Fatalf("GET /api/v1/me = %d %v, want 200 with a CSRF token", rec.Code, me) } patch := `{"dinner_time":"18:45"}` refused := []struct { name string req *http.Request status int }{ {"no session", httptest.NewRequest(http.MethodPatch, settingsPath, strings.NewReader(patch)), http.StatusUnauthorized}, {"no CSRF token", withCookie(httptest.NewRequest(http.MethodPatch, settingsPath, strings.NewReader(patch))), http.StatusForbidden}, } for _, tt := range refused { if rec, body := serve(t, router, tt.req); rec.Code != tt.status { t.Errorf("%s: PATCH status = %d, want %d: %v", tt.name, rec.Code, tt.status, body) } } _, body := serve(t, router, withCookie(httptest.NewRequest(http.MethodGet, settingsPath, nil))) if body["dinner_time"] != household.SeedDinnerTime || body["updated_by"] != nil { t.Fatalf("refused PATCH changed settings: %v", body) } req := withCookie(httptest.NewRequest(http.MethodPatch, settingsPath, strings.NewReader(patch))) req.Header.Set(CSRFHeader, token) rec, body = serve(t, router, req) if rec.Code != http.StatusOK { t.Fatalf("PATCH status = %d, want 200: %v", rec.Code, body) } if body["dinner_time"] != "18:45" || body["updated_by"] != "authentik-sub-42" { t.Errorf("PATCH = %v, want dinner_time 18:45 updated_by authentik-sub-42", body) } }