CI / ci (push) Successful in 8m31s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
538 lines
20 KiB
Go
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.
|
|
}
|