Files
MealQuest/internal/server/google_test.go
T
2026-10-09 18:04:05 -04:00

538 lines
20 KiB
Go

package server
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
"testing/fstest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const (
googleConnectPath = "/api/v1/integrations/google/connect"
googleCallbackPath = "/api/v1/integrations/google/callback"
googlePath = "/api/v1/integrations/google"
googleCalendarsPath = "/api/v1/integrations/google/calendars"
googleCalendarPath = "/api/v1/integrations/google/calendar"
)
// googleEnv is a router whose Google Calendar service talks to a fake
// Google, with every log line it writes.
type googleEnv struct {
h http.Handler
google *gcaltest.Google
st *store.Store
box *secretbox.Box
logs *bytes.Buffer
// seen collects every response body and header, to check none holds a
// secret.
seen strings.Builder
}
func newGoogleEnv(t *testing.T) *googleEnv {
t.Helper()
pub, err := url.Parse("https://mealquest.example")
if err != nil {
t.Fatal(err)
}
e := &googleEnv{
google: gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath),
st: newTestStore(t),
box: newTestBox(t),
logs: &bytes.Buffer{},
}
svc, err := gcal.NewService(e.st, e.box, gcal.Config{
ClientID: gcaltest.ClientID,
ClientSecret: gcaltest.ClientSecret,
PublicURL: pub,
SecretKey: []byte("server-secret-key"),
Endpoint: e.google.Endpoint(),
RevokeURL: e.google.RevokeURL(),
CalendarAPI: e.google.CalendarAPI(),
})
if err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t),
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} {
if strings.Contains(e.seen.String(), secret) {
t.Errorf("a response holds a secret: %s", e.seen.String())
}
if strings.Contains(e.logs.String(), secret) {
t.Errorf("the logs hold a secret: %s", e.logs.String())
}
}
})
return e
}
func (e *googleEnv) serve(req *http.Request) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
e.seen.WriteString(rec.Body.String())
for k, vs := range rec.Header() {
e.seen.WriteString(k + ": " + strings.Join(vs, ", ") + "\n")
}
return rec
}
// connect starts a connect as the test session and returns Google's consent
// URL and the connect cookie.
func (e *googleEnv) connect(t *testing.T) (*url.URL, *http.Cookie) {
t.Helper()
rec := e.serve(signedIn(http.MethodGet, googleConnectPath, nil))
if rec.Code != http.StatusFound {
t.Fatalf("connect = %d %s, want 302", rec.Code, rec.Body)
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
t.Errorf("connect Cache-Control = %q, want no-store", cc)
}
consent, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
var cookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == "__Host-mealquest_google" {
cookie = c
}
}
if cookie == nil {
t.Fatal("connect set no connect cookie")
}
return consent, cookie
}
// callback returns to the callback as the test session with the connect
// cookie and query.
func (e *googleEnv) callback(cookie *http.Cookie, query url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, googleCallbackPath+"?"+query.Encode(), nil)
req.AddCookie(validSessionCookie)
if cookie != nil {
req.AddCookie(cookie)
}
return e.serve(req)
}
func (e *googleEnv) consented(consent *url.URL) url.Values {
q := consent.Query()
return url.Values{"state": {q.Get("state")}, "code": {e.google.Authorize(q.Get("code_challenge"))}}
}
func (e *googleEnv) stored(t *testing.T) bool {
t.Helper()
_, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background())
if errors.Is(err, sql.ErrNoRows) {
return false
}
if err != nil {
t.Fatal(err)
}
return true
}
func errorBodyCode(t *testing.T, rec *httptest.ResponseRecorder) (code, message string) {
t.Helper()
var body struct {
Error apiError `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return body.Error.Code, body.Error.Message
}
func TestGoogleConnectAndDisconnect(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
consent, cookie := e.connect(t)
q := consent.Query()
if consent.Host != strings.TrimPrefix(e.google.Server.URL, "http://") || consent.Path != "/auth" {
t.Errorf("consent URL = %s, want the fake's /auth", consent)
}
if q.Get("access_type") != "offline" || q.Get("prompt") != "consent" ||
q.Get("scope") != strings.Join(gcal.Scopes, " ") ||
q.Get("redirect_uri") != "https://mealquest.example"+gcal.CallbackPath {
t.Errorf("consent query = %v", q)
}
rec := e.callback(cookie, e.consented(consent))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/settings/calendar" {
t.Fatalf("callback = %d Location %q %s, want 303 to /settings/calendar", rec.Code, rec.Header().Get("Location"), rec.Body)
}
row, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background())
if err != nil {
t.Fatal(err)
}
if plain, err := e.box.Decrypt(row.RefreshTokenCt); err != nil || string(plain) != gcaltest.RefreshToken {
t.Errorf("stored token opens to %q, %v", plain, err)
}
if row.ConnectedAt == "" {
t.Error("connected_at not set")
}
// DELETE needs the CSRF header like every other write.
req := httptest.NewRequest(http.MethodDelete, googlePath, nil)
req.AddCookie(validSessionCookie)
if rec := e.serve(req); rec.Code != http.StatusForbidden {
t.Errorf("DELETE without CSRF = %d, want 403", rec.Code)
}
if len(e.google.Revoked()) != 0 || !e.stored(t) {
t.Fatal("a DELETE without CSRF revoked or cleared the connection")
}
rec = e.serve(signedIn(http.MethodDelete, googlePath, nil))
if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
t.Fatalf("DELETE = %d %q, want 204 with no body", rec.Code, rec.Body)
}
if got := e.google.Revoked(); !slices.Equal(got, []string{gcaltest.RefreshToken}) {
t.Errorf("revoked %q, want the refresh token", got)
}
if e.stored(t) {
t.Error("connection still stored after DELETE")
}
rec = e.serve(signedIn(http.MethodDelete, googlePath, nil))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusConflict || code != "not_connected" {
t.Errorf("second DELETE = %d %s, want 409 not_connected", rec.Code, rec.Body)
}
}
func TestGoogleCallbackBadState(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
consent, cookie := e.connect(t)
good := e.consented(consent)
for name, tc := range map[string]struct {
cookie *http.Cookie
query url.Values
}{
"missing state": {cookie, url.Values{"code": good["code"]}},
"wrong state": {cookie, url.Values{"state": {"forged"}, "code": good["code"]}},
"missing cookie": {nil, good},
} {
rec := e.callback(tc.cookie, tc.query)
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadRequest || code != "invalid_state" {
t.Errorf("%s: callback = %d %s, want 400 invalid_state", name, rec.Code, rec.Body)
}
}
if e.google.TokenCalls() != 0 {
t.Error("a code was exchanged despite a bad state")
}
if e.stored(t) {
t.Error("a connection was stored")
}
}
func TestGoogleCallbackWithoutRefreshTokenAsksToReconnect(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.google.OmitRefreshToken()
consent, cookie := e.connect(t)
rec := e.callback(cookie, e.consented(consent))
code, message := errorBodyCode(t, rec)
if rec.Code != http.StatusBadGateway || code != "reconnect_required" || !strings.Contains(message, "connect Google Calendar again") {
t.Errorf("callback = %d %s, want 502 reconnect_required asking to connect again", rec.Code, rec.Body)
}
if e.stored(t) {
t.Error("a connection was stored without a refresh token")
}
}
func TestGoogleCallbackFailuresAreRedacted(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.google.FailToken(http.StatusBadRequest)
consent, cookie := e.connect(t)
rec := e.callback(cookie, e.consented(consent))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadGateway || code != "google_failed" {
t.Errorf("callback = %d %s, want 502 google_failed", rec.Code, rec.Body)
}
// newGoogleEnv's cleanup checks the response and logs hold no secret.
}
func TestGoogleRevokeFailureKeepsConnection(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
consent, cookie := e.connect(t)
if rec := e.callback(cookie, e.consented(consent)); rec.Code != http.StatusSeeOther {
t.Fatalf("callback = %d %s", rec.Code, rec.Body)
}
e.google.AnswerRevoke(http.StatusServiceUnavailable, "")
rec := e.serve(signedIn(http.MethodDelete, googlePath, nil))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadGateway || code != "revoke_failed" {
t.Errorf("DELETE = %d %s, want 502 revoke_failed", rec.Code, rec.Body)
}
if !e.stored(t) {
t.Error("connection cleared although Google did not revoke")
}
}
func TestGoogleRoutesRequireSession(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
for _, c := range []struct{ method, path string }{
{http.MethodGet, googleConnectPath},
{http.MethodGet, googleCallbackPath + "?state=s&code=c"},
{http.MethodDelete, googlePath},
{http.MethodGet, googleCalendarsPath},
{http.MethodPut, googleCalendarPath},
} {
rec := e.serve(httptest.NewRequest(c.method, c.path, nil))
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
t.Errorf("%s %s signed out = %d %s, want 401", c.method, c.path, rec.Code, rec.Body)
}
}
if e.google.TokenCalls() != 0 || len(e.google.Revoked()) != 0 || e.google.CalendarCalls() != 0 {
t.Error("a signed-out request reached Google")
}
}
// connected connects the test session's Google account and has the fake
// list an owned primary calendar, a writable shared one and a read-only one.
func (e *googleEnv) connected(t *testing.T) {
t.Helper()
consent, cookie := e.connect(t)
if rec := e.callback(cookie, e.consented(consent)); rec.Code != http.StatusSeeOther {
t.Fatalf("callback = %d %s", rec.Code, rec.Body)
}
e.google.SetCalendars(0,
gcaltest.Calendar{ID: "household@gmail.com", Summary: "household@gmail.com", Primary: true, AccessRole: "owner"},
gcaltest.Calendar{ID: "family@group.calendar.google.com", Summary: "Family", AccessRole: "writer", Description: "private notes"},
gcaltest.Calendar{ID: "holidays@group.v.calendar.google.com", Summary: "Holidays", AccessRole: "reader"},
)
}
func chooseCalendar(id string) *http.Request {
return signedIn(http.MethodPut, googleCalendarPath, strings.NewReader(`{"calendar_id":"`+id+`"}`))
}
func TestGoogleCalendarsListAndChoose(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
rec := e.serve(signedIn(http.MethodGet, googleCalendarsPath, nil))
want := `[{"id":"household@gmail.com","summary":"household@gmail.com","primary":true,"accessRole":"owner"},` +
`{"id":"family@group.calendar.google.com","summary":"Family","primary":false,"accessRole":"writer"}]`
if rec.Code != http.StatusOK || rec.Body.String() != want {
t.Fatalf("GET calendars = %d %s, want 200 %s", rec.Code, rec.Body, want)
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
t.Errorf("Cache-Control = %q, want no-store", cc)
}
// PUT needs the CSRF header like every other write.
req := httptest.NewRequest(http.MethodPut, googleCalendarPath, strings.NewReader(`{"calendar_id":"family@group.calendar.google.com"}`))
req.AddCookie(validSessionCookie)
if rec := e.serve(req); rec.Code != http.StatusForbidden {
t.Errorf("PUT without CSRF = %d, want 403", rec.Code)
}
if row, _ := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background()); row.CalendarID.Valid {
t.Fatal("a PUT without CSRF chose a calendar")
}
rec = e.serve(chooseCalendar("family@group.calendar.google.com"))
if want := `{"calendar_id":"family@group.calendar.google.com","calendar_name":"Family"}`; rec.Code != http.StatusOK || rec.Body.String() != want {
t.Fatalf("PUT calendar = %d %s, want 200 %s", rec.Code, rec.Body, want)
}
row, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background())
if err != nil {
t.Fatal(err)
}
if row.CalendarID.String != "family@group.calendar.google.com" || row.CalendarName.String != "Family" {
t.Errorf("stored calendar = %v %v", row.CalendarID, row.CalendarName)
}
for _, id := range []string{"holidays@group.v.calendar.google.com", "nobody@group.calendar.google.com"} {
rec := e.serve(chooseCalendar(id))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusUnprocessableEntity || code != "unknown_calendar" {
t.Errorf("PUT %s = %d %s, want 422 unknown_calendar", id, rec.Code, rec.Body)
}
}
if row, _ := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background()); row.CalendarID.String != "family@group.calendar.google.com" {
t.Errorf("a refused choice replaced the stored calendar with %v", row.CalendarID)
}
}
func TestGoogleCalendarChoiceBadRequest(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
calls := e.google.CalendarCalls()
for _, body := range []string{``, `{}`, `{"calendar_id":""}`, `{"calendar_id":null}`, `{"calendar_id":7}`,
`{"calendar_id":"family@group.calendar.google.com","calendar_name":"Mine"}`} {
rec := e.serve(signedIn(http.MethodPut, googleCalendarPath, strings.NewReader(body)))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadRequest || code != "invalid_request" {
t.Errorf("PUT %q = %d %s, want 400 invalid_request", body, rec.Code, rec.Body)
}
}
if e.google.CalendarCalls() != calls {
t.Error("a bad request reached Google")
}
}
func TestGoogleCalendarsNotConnected(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
for _, req := range []*http.Request{
signedIn(http.MethodGet, googleCalendarsPath, nil),
chooseCalendar("family@group.calendar.google.com"),
} {
rec := e.serve(req)
code, message := errorBodyCode(t, rec)
if rec.Code != http.StatusConflict || code != "not_connected" || message != "Google Calendar is not connected" {
t.Errorf("%s %s = %d %s, want 409 not_connected", req.Method, req.URL.Path, rec.Code, rec.Body)
}
}
if e.google.TokenCalls() != 0 || e.google.CalendarCalls() != 0 {
t.Error("Google was called with nothing connected")
}
}
// getGoogleStatus answers GET /integrations/google, decoded.
func (e *googleEnv) getGoogleStatus(t *testing.T) map[string]any {
t.Helper()
rec := e.serve(signedIn(http.MethodGet, googlePath, nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d %s, want 200", googlePath, rec.Code, rec.Body)
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
t.Errorf("GET %s Cache-Control = %q, want no-store", googlePath, cc)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
return body
}
// TestGoogleCalendarsGrantRevoked lists and chooses after Google stopped
// accepting MealQuest's grant — the household removed its access in their
// Google account (invalid_grant), or Google refuses the access token just
// refreshed (HTTP 401): 409 google_reconnect_required, the refresh token is
// cleared, and GET /integrations/google reports reconnect_required with the
// reason, keeping the chosen calendar.
func TestGoogleCalendarsGrantRevoked(t *testing.T) {
t.Parallel()
for name, refuse := range map[string]func(*gcaltest.Google){
"invalid_grant": (*gcaltest.Google).RevokeGrant,
"401 after a refresh": (*gcaltest.Google).RejectAccessToken,
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
if rec := e.serve(chooseCalendar("family@group.calendar.google.com")); rec.Code != http.StatusOK {
t.Fatalf("PUT calendar = %d %s", rec.Code, rec.Body)
}
refuse(e.google)
for _, req := range []*http.Request{
signedIn(http.MethodGet, googleCalendarsPath, nil),
chooseCalendar("household@gmail.com"),
} {
rec := e.serve(req)
code, message := errorBodyCode(t, rec)
if rec.Code != http.StatusConflict || code != "google_reconnect_required" || !strings.Contains(message, "connect Google Calendar again") {
t.Errorf("%s %s = %d %s, want 409 google_reconnect_required", req.Method, req.URL.Path, rec.Code, rec.Body)
}
}
got := e.getGoogleStatus(t)
if got["status"] != "reconnect_required" || got["last_sync_error"] != "Google access was revoked or expired — reconnect" ||
got["calendar_id"] != "family@group.calendar.google.com" {
t.Errorf("GET %s = %v, want reconnect_required with the reason and the calendar kept", googlePath, got)
}
row, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background())
if err != nil || row.RefreshTokenCt != nil {
t.Errorf("connection = %+v, %v; want it kept without a refresh token", row, err)
}
// newGoogleEnv's cleanup checks the responses and logs hold no secret.
})
}
}
// TestGoogleCalendarsTransientFailure fails the calendar list with HTTP 503
// on every try: it is tried three times, then 503 google_unavailable,
// retryable, and the connection stays connected.
func TestGoogleCalendarsTransientFailure(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
e.google.FailCalendarList(http.StatusServiceUnavailable)
calls := e.google.CalendarCalls()
rec := e.serve(signedIn(http.MethodGet, googleCalendarsPath, nil))
var body struct {
Error apiError `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusServiceUnavailable || body.Error.Code != "google_unavailable" || !body.Error.Retryable {
t.Errorf("GET calendars = %d %s, want 503 google_unavailable, retryable", rec.Code, rec.Body)
}
if n := e.google.CalendarCalls() - calls; n != 3 {
t.Errorf("calendar list tried %d times, want 3", n)
}
if got := e.getGoogleStatus(t); got["status"] != "connected" || got["last_sync_error"] != nil {
t.Errorf("GET %s = %v, want connected with no error", googlePath, got)
}
if !e.stored(t) {
t.Error("the connection was cleared")
}
// newGoogleEnv's cleanup checks the response and logs hold no secret.
}
func TestGoogleStatus(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
if got := e.getGoogleStatus(t); got["status"] != "not_connected" {
t.Errorf("GET %s = %v, want not_connected", googlePath, got)
}
e.connected(t)
got := e.getGoogleStatus(t)
if got["status"] != "connected" || got["connected_at"] == nil || got["calendar_id"] != nil || got["last_sync_error"] != nil {
t.Errorf("GET %s = %v, want connected with no calendar", googlePath, got)
}
if len(got) != 5 {
t.Errorf("GET %s = %v, want exactly status, calendar_id, calendar_name, connected_at and last_sync_error", googlePath, got)
}
}
func TestGoogleCalendarsFailureIsRedacted(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
e.google.FailCalendarList(http.StatusForbidden)
rec := e.serve(signedIn(http.MethodGet, googleCalendarsPath, nil))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadGateway || code != "google_failed" {
t.Errorf("GET calendars = %d %s, want 502 google_failed", rec.Code, rec.Body)
}
if !strings.Contains(e.logs.String(), "HTTP 403") {
t.Errorf("logs = %s, want the failure's status", e.logs)
}
// newGoogleEnv's cleanup checks the response and logs hold no secret.
}