Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #130.
This commit is contained in:
@@ -0,0 +1,537 @@
|
||||
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),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), svc, 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.
|
||||
}
|
||||
Reference in New Issue
Block a user