When Google refuses the stored grant (invalid_grant on refresh, or HTTP 401 from the Calendar API to the access token just refreshed) on any Calendar call - listing, choosing or pushing - the connection becomes reconnect required under the account lock: the refresh token is cleared and last_sync_error set to "Google access was revoked or expired — reconnect". The call answers 409 google_reconnect_required, and later calls answer the same without contacting Google until a reconnect or a disconnect. The chosen calendar and stored calendar event ids are kept: the pushed events are still on that calendar, so forgetEvents is not run. HTTP 429 and 5xx from the token endpoint or the Calendar API are retried at most twice with backoff, then answered 503 google_unavailable, retryable, without clearing or disconnecting anything. Add GET /api/v1/integrations/google, reporting not_connected, connected or reconnect_required with the chosen calendar and last_sync_error. Migration 0015 rebuilds integrations_google so refresh_token_ct can be cleared, with a CHECK that a row without a token carries the reason. Maestro task b8d8da5a-0450-4571-963e-b3dfead24283. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
590 lines
19 KiB
Go
590 lines
19 KiB
Go
package gcal_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
|
"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"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
|
)
|
|
|
|
const publicURL = "https://meals.example.com/app"
|
|
|
|
var (
|
|
redirectURL = "https://meals.example.com" + gcal.CallbackPath
|
|
session = auth.Session{Subject: "user-1", CSRFToken: "csrf-session-one"}
|
|
other = auth.Session{Subject: "user-1", CSRFToken: "csrf-session-two"}
|
|
)
|
|
|
|
type harness struct {
|
|
svc *gcal.Service
|
|
google *gcaltest.Google
|
|
st *store.Store
|
|
box *secretbox.Box
|
|
clock time.Time
|
|
}
|
|
|
|
func newHarness(t *testing.T) *harness {
|
|
t.Helper()
|
|
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)
|
|
}
|
|
key := make([]byte, secretbox.KeySize)
|
|
rand.Read(key)
|
|
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pub, err := url.Parse(publicURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
google := gcaltest.New(t, redirectURL)
|
|
svc, err := gcal.NewService(st, box, gcal.Config{
|
|
ClientID: gcaltest.ClientID,
|
|
ClientSecret: gcaltest.ClientSecret,
|
|
PublicURL: pub,
|
|
SecretKey: []byte("server-secret-key"),
|
|
Endpoint: google.Endpoint(),
|
|
RevokeURL: google.RevokeURL(),
|
|
CalendarAPI: google.CalendarAPI(),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := &harness{svc: svc, google: google, st: st, box: box, clock: time.Date(2026, 10, 7, 18, 30, 0, 0, time.UTC)}
|
|
gcal.SetClock(svc, func() time.Time { return h.clock })
|
|
gcal.SetRetryDelay(svc, 0)
|
|
return h
|
|
}
|
|
|
|
// connect is a started connect: Google's consent URL and the connect
|
|
// cookie.
|
|
type connect struct {
|
|
consent *url.URL
|
|
cookie *http.Cookie
|
|
}
|
|
|
|
func (h *harness) connect(t *testing.T, s auth.Session) connect {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
raw, err := h.svc.Connect(rec, s)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
consent, err := url.Parse(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cookies := rec.Result().Cookies()
|
|
if len(cookies) != 1 {
|
|
t.Fatalf("Connect set %d cookies, want 1", len(cookies))
|
|
}
|
|
return connect{consent: consent, cookie: cookies[0]}
|
|
}
|
|
|
|
// callback completes c as session s with query, returning the error and the
|
|
// recorder Complete wrote to.
|
|
func (h *harness) callback(c connect, s auth.Session, query url.Values) (*httptest.ResponseRecorder, error) {
|
|
req := httptest.NewRequest(http.MethodGet, gcal.CallbackPath+"?"+query.Encode(), nil)
|
|
if c.cookie != nil {
|
|
req.AddCookie(c.cookie)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
return rec, h.svc.Complete(rec, req, s)
|
|
}
|
|
|
|
// consented is the query Google sends back after consent to c.
|
|
func (h *harness) consented(c connect) url.Values {
|
|
q := c.consent.Query()
|
|
return url.Values{"state": {q.Get("state")}, "code": {h.google.Authorize(q.Get("code_challenge"))}}
|
|
}
|
|
|
|
func (h *harness) row(t *testing.T) (db.IntegrationsGoogle, bool) {
|
|
t.Helper()
|
|
row, err := db.New(h.st.Reader).GetIntegrationsGoogle(context.Background())
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return db.IntegrationsGoogle{}, false
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return row, true
|
|
}
|
|
|
|
func TestConnectRedirectsToConsent(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
c := h.connect(t, session)
|
|
|
|
if got, want := c.consent.Scheme+"://"+c.consent.Host+c.consent.Path, h.google.Endpoint().AuthURL; got != want {
|
|
t.Errorf("consent URL = %s, want %s", got, want)
|
|
}
|
|
q := c.consent.Query()
|
|
for name, want := range map[string]string{
|
|
"client_id": gcaltest.ClientID,
|
|
"redirect_uri": redirectURL,
|
|
"response_type": "code",
|
|
"access_type": "offline",
|
|
"prompt": "consent",
|
|
"code_challenge_method": "S256",
|
|
} {
|
|
if got := q.Get(name); got != want {
|
|
t.Errorf("%s = %q, want %q", name, got, want)
|
|
}
|
|
}
|
|
if got := strings.Fields(q.Get("scope")); !slices.Equal(got, []string{
|
|
"https://www.googleapis.com/auth/calendar.events",
|
|
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
|
|
}) {
|
|
t.Errorf("scope = %q", q.Get("scope"))
|
|
}
|
|
if q.Get("state") == "" || q.Get("code_challenge") == "" {
|
|
t.Errorf("state %q, code_challenge %q: want both set", q.Get("state"), q.Get("code_challenge"))
|
|
}
|
|
if q.Get("client_secret") != "" || strings.Contains(c.consent.String(), gcaltest.ClientSecret) {
|
|
t.Error("consent URL carries the client secret")
|
|
}
|
|
|
|
ck := c.cookie
|
|
if ck.Name != "__Host-mealquest_google" || ck.Path != "/" || !ck.Secure || !ck.HttpOnly ||
|
|
ck.SameSite != http.SameSiteLaxMode || ck.MaxAge != 600 || ck.Domain != "" {
|
|
t.Errorf("connect cookie = %+v, want __Host-, Path=/, Secure, HttpOnly, Lax, 10 minutes", ck)
|
|
}
|
|
// Two connects never share a state.
|
|
if again := h.connect(t, session); again.consent.Query().Get("state") == q.Get("state") {
|
|
t.Error("two connects share a state")
|
|
}
|
|
}
|
|
|
|
func TestCompleteStoresSealedRefreshToken(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
c := h.connect(t, session)
|
|
rec, err := h.callback(c, session, h.consented(c))
|
|
if err != nil {
|
|
t.Fatalf("Complete: %v", err)
|
|
}
|
|
|
|
row, ok := h.row(t)
|
|
if !ok {
|
|
t.Fatal("no integration stored")
|
|
}
|
|
if bytes.Contains(row.RefreshTokenCt, []byte(gcaltest.RefreshToken)) {
|
|
t.Error("refresh token stored in plain text")
|
|
}
|
|
plain, err := h.box.Decrypt(row.RefreshTokenCt)
|
|
if err != nil || string(plain) != gcaltest.RefreshToken {
|
|
t.Errorf("stored token opens to %q, %v; want the refresh token", plain, err)
|
|
}
|
|
if want := "2026-10-07T18:30:00.000Z"; row.ConnectedAt != want || row.CreatedAt != want || row.UpdatedAt != want {
|
|
t.Errorf("connected_at %q created_at %q updated_at %q, want %s", row.ConnectedAt, row.CreatedAt, row.UpdatedAt, want)
|
|
}
|
|
if row.CalendarID.Valid || row.CalendarName.Valid || row.LastSyncError.Valid {
|
|
t.Errorf("new connection has calendar or sync error: %+v", row)
|
|
}
|
|
assertCookieCleared(t, rec)
|
|
assertNoSecret(t, "response", rec.Body.String()+headerText(rec.Header()))
|
|
}
|
|
|
|
func TestReconnectReplacesTokenAndKeepsCalendar(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
ctx := context.Background()
|
|
if _, err := db.New(h.st.Writer).UpsertIntegrationsGoogle(ctx, db.UpsertIntegrationsGoogleParams{
|
|
RefreshTokenCt: []byte("old sealed token"),
|
|
CalendarID: sql.NullString{String: "family@group.calendar.google.com", Valid: true},
|
|
CalendarName: sql.NullString{String: "Family", Valid: true},
|
|
ConnectedAt: "2026-09-01T00:00:00.000Z",
|
|
LastSyncError: sql.NullString{String: "token revoked", Valid: true},
|
|
CreatedAt: "2026-09-01T00:00:00.000Z",
|
|
UpdatedAt: "2026-09-01T00:00:00.000Z",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
c := h.connect(t, session)
|
|
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
|
t.Fatalf("Complete: %v", err)
|
|
}
|
|
row, _ := h.row(t)
|
|
if plain, err := h.box.Decrypt(row.RefreshTokenCt); err != nil || string(plain) != gcaltest.RefreshToken {
|
|
t.Errorf("token after reconnect opens to %q, %v", plain, err)
|
|
}
|
|
if row.CalendarID.String != "family@group.calendar.google.com" || row.CalendarName.String != "Family" {
|
|
t.Errorf("calendar after reconnect = %v %v, want it kept", row.CalendarID, row.CalendarName)
|
|
}
|
|
if row.LastSyncError.Valid {
|
|
t.Errorf("last_sync_error = %q, want it cleared", row.LastSyncError.String)
|
|
}
|
|
if row.CreatedAt != "2026-09-01T00:00:00.000Z" || row.ConnectedAt != "2026-10-07T18:30:00.000Z" {
|
|
t.Errorf("created_at %q connected_at %q", row.CreatedAt, row.ConnectedAt)
|
|
}
|
|
}
|
|
|
|
func TestCompleteRefusesBadState(t *testing.T) {
|
|
t.Parallel()
|
|
for _, tc := range []struct {
|
|
name string
|
|
// edit changes the connect, the callback's session or its query.
|
|
edit func(t *testing.T, h *harness, c *connect, s *auth.Session, q url.Values)
|
|
}{
|
|
{"missing state", func(_ *testing.T, _ *harness, _ *connect, _ *auth.Session, q url.Values) { q.Del("state") }},
|
|
{"wrong state", func(_ *testing.T, _ *harness, _ *connect, _ *auth.Session, q url.Values) { q.Set("state", "forged") }},
|
|
{"no connect cookie", func(_ *testing.T, _ *harness, c *connect, _ *auth.Session, _ url.Values) { c.cookie = nil }},
|
|
{"tampered cookie", func(_ *testing.T, _ *harness, c *connect, _ *auth.Session, _ url.Values) {
|
|
c.cookie = &http.Cookie{Name: c.cookie.Name, Value: "x" + c.cookie.Value}
|
|
}},
|
|
{"another session", func(_ *testing.T, _ *harness, _ *connect, s *auth.Session, _ url.Values) { *s = other }},
|
|
{"expired", func(_ *testing.T, h *harness, _ *connect, _ *auth.Session, _ url.Values) {
|
|
h.clock = h.clock.Add(10 * time.Minute)
|
|
}},
|
|
{"state of another connect", func(t *testing.T, h *harness, _ *connect, _ *auth.Session, q url.Values) {
|
|
q.Set("state", h.connect(t, session).consent.Query().Get("state"))
|
|
}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
c := h.connect(t, session)
|
|
s := session
|
|
q := h.consented(c)
|
|
tc.edit(t, h, &c, &s, q)
|
|
|
|
rec, err := h.callback(c, s, q)
|
|
if !errors.Is(err, gcal.ErrBadState) {
|
|
t.Fatalf("Complete error = %v, want ErrBadState", err)
|
|
}
|
|
if h.google.TokenCalls() != 0 {
|
|
t.Error("the code was exchanged despite the bad state")
|
|
}
|
|
if _, ok := h.row(t); ok {
|
|
t.Error("an integration was stored")
|
|
}
|
|
assertCookieCleared(t, rec)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCompleteRefusesReplayedState presents a connect's cookie and state a
|
|
// second time, with a second valid code from the same consent URL, as an
|
|
// attacker holding a copy of the cookie could. The server refuses it,
|
|
// whatever the browser did with the cleared cookie.
|
|
func TestCompleteRefusesReplayedState(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
c := h.connect(t, session)
|
|
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
|
t.Fatalf("Complete: %v", err)
|
|
}
|
|
stored, _ := h.row(t)
|
|
|
|
_, err := h.callback(c, session, h.consented(c))
|
|
if !errors.Is(err, gcal.ErrBadState) {
|
|
t.Fatalf("replayed Complete error = %v, want ErrBadState", err)
|
|
}
|
|
if got := h.google.TokenCalls(); got != 1 {
|
|
t.Errorf("token endpoint called %d times, want once: the replayed code was exchanged", got)
|
|
}
|
|
if row, _ := h.row(t); !bytes.Equal(row.RefreshTokenCt, stored.RefreshTokenCt) {
|
|
t.Error("the replay replaced the stored token")
|
|
}
|
|
}
|
|
|
|
func TestCompleteRefusesMissingRefreshToken(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
h.google.OmitRefreshToken()
|
|
c := h.connect(t, session)
|
|
_, err := h.callback(c, session, h.consented(c))
|
|
if !errors.Is(err, gcal.ErrNoRefreshToken) {
|
|
t.Fatalf("Complete error = %v, want ErrNoRefreshToken", err)
|
|
}
|
|
if _, ok := h.row(t); ok {
|
|
t.Error("an integration was stored without a refresh token")
|
|
}
|
|
assertNoSecret(t, "error", err.Error())
|
|
}
|
|
|
|
func TestCompleteRefusesMissingScope(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
h.google.GrantScope("https://www.googleapis.com/auth/calendar.calendarlist.readonly")
|
|
c := h.connect(t, session)
|
|
_, err := h.callback(c, session, h.consented(c))
|
|
if !errors.Is(err, gcal.ErrScopes) {
|
|
t.Fatalf("Complete error = %v, want ErrScopes", err)
|
|
}
|
|
if _, ok := h.row(t); ok {
|
|
t.Error("an integration was stored without calendar.events")
|
|
}
|
|
}
|
|
|
|
func TestCompleteDenied(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
c := h.connect(t, session)
|
|
q := url.Values{"state": {c.consent.Query().Get("state")}, "error": {"access_denied"}}
|
|
if _, err := h.callback(c, session, q); !errors.Is(err, gcal.ErrDenied) {
|
|
t.Fatalf("Complete error = %v, want ErrDenied", err)
|
|
}
|
|
if h.google.TokenCalls() != 0 {
|
|
t.Error("token endpoint called after a denial")
|
|
}
|
|
}
|
|
|
|
func TestCompleteExchangeFailureIsRedacted(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
h.google.FailToken(http.StatusBadRequest)
|
|
c := h.connect(t, session)
|
|
_, err := h.callback(c, session, h.consented(c))
|
|
if err == nil {
|
|
t.Fatal("Complete succeeded despite the token endpoint failing")
|
|
}
|
|
if !strings.Contains(err.Error(), "HTTP 400") {
|
|
t.Errorf("error = %v, want the status", err)
|
|
}
|
|
assertNoSecret(t, "error", err.Error())
|
|
if _, ok := h.row(t); ok {
|
|
t.Error("an integration was stored")
|
|
}
|
|
}
|
|
|
|
func TestDisconnectRevokesAndClears(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
c := h.connect(t, session)
|
|
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := h.svc.Disconnect(context.Background()); err != nil {
|
|
t.Fatalf("Disconnect: %v", err)
|
|
}
|
|
if got := h.google.Revoked(); !slices.Equal(got, []string{gcaltest.RefreshToken}) {
|
|
t.Errorf("revoked %q, want the refresh token", got)
|
|
}
|
|
if _, ok := h.row(t); ok {
|
|
t.Error("integration still stored after disconnect")
|
|
}
|
|
if err := h.svc.Disconnect(context.Background()); !errors.Is(err, gcal.ErrNotConnected) {
|
|
t.Errorf("second Disconnect = %v, want ErrNotConnected", err)
|
|
}
|
|
}
|
|
|
|
// TestDisconnectRacingReconnect drives a disconnect and a reconnect into
|
|
// each other at Google, with distinct tokens. However they interleave, the
|
|
// end state is consistent: disconnected, or connected with a token Google
|
|
// has not revoked.
|
|
func TestDisconnectRacingReconnect(t *testing.T) {
|
|
t.Parallel()
|
|
for _, tc := range []struct {
|
|
name string
|
|
// hold stops the request the other operation must not slip past.
|
|
hold func(*gcaltest.Google) (<-chan struct{}, func())
|
|
// first is held at Google while second runs.
|
|
first, second func(h *harness, c connect, q url.Values) error
|
|
}{
|
|
{
|
|
// The disconnect has read the stored token and is revoking it
|
|
// when the reconnect would store a new one.
|
|
name: "reconnect during revoke",
|
|
hold: (*gcaltest.Google).HoldRevoke,
|
|
first: disconnect,
|
|
second: reconnect,
|
|
},
|
|
{
|
|
// The reconnect's new token is issued but not yet stored when
|
|
// the disconnect would revoke the old one.
|
|
name: "disconnect during exchange",
|
|
hold: (*gcaltest.Google).HoldToken,
|
|
first: reconnect,
|
|
second: disconnect,
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
first := h.connect(t, session)
|
|
if _, err := h.callback(first, session, h.consented(first)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := h.connect(t, session)
|
|
q := h.consented(c)
|
|
|
|
arrived, release := tc.hold(h.google)
|
|
defer release()
|
|
firstDone := make(chan error, 1)
|
|
go func() { firstDone <- tc.first(h, c, q) }()
|
|
<-arrived
|
|
secondDone := make(chan error, 1)
|
|
go func() { secondDone <- tc.second(h, c, q) }()
|
|
// Let the second operation run as far as it can while the first
|
|
// is held at Google: to the end when nothing serializes them.
|
|
select {
|
|
case err := <-secondDone:
|
|
secondDone <- err
|
|
case <-time.After(500 * time.Millisecond):
|
|
}
|
|
release()
|
|
for _, done := range []chan error{firstDone, secondDone} {
|
|
if err := <-done; err != nil {
|
|
t.Logf("operation: %v", err)
|
|
}
|
|
}
|
|
|
|
row, ok := h.row(t)
|
|
if !ok {
|
|
return
|
|
}
|
|
plain, err := h.box.Decrypt(row.RefreshTokenCt)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !h.google.Live(string(plain)) {
|
|
t.Errorf("stored as connected with a revoked token (Google revoked %d tokens)", len(h.google.Revoked()))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func disconnect(h *harness, _ connect, _ url.Values) error {
|
|
return h.svc.Disconnect(context.Background())
|
|
}
|
|
|
|
func reconnect(h *harness, c connect, q url.Values) error {
|
|
_, err := h.callback(c, session, q)
|
|
return err
|
|
}
|
|
|
|
func TestDisconnectTokenAlreadyRevokedAtGoogle(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
c := h.connect(t, session)
|
|
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h.google.AnswerRevoke(http.StatusBadRequest, `{"error":"invalid_token","error_description":"Token expired or revoked"}`)
|
|
if err := h.svc.Disconnect(context.Background()); err != nil {
|
|
t.Fatalf("Disconnect: %v", err)
|
|
}
|
|
if _, ok := h.row(t); ok {
|
|
t.Error("integration still stored")
|
|
}
|
|
}
|
|
|
|
func TestDisconnectRevokeFailureKeepsConnection(t *testing.T) {
|
|
t.Parallel()
|
|
for _, status := range []int{http.StatusBadRequest, http.StatusServiceUnavailable} {
|
|
h := newHarness(t)
|
|
c := h.connect(t, session)
|
|
if _, err := h.callback(c, session, h.consented(c)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h.google.AnswerRevoke(status, "")
|
|
err := h.svc.Disconnect(context.Background())
|
|
var revoke *gcal.RevokeError
|
|
if !errors.As(err, &revoke) {
|
|
t.Fatalf("HTTP %d: Disconnect error = %v, want *RevokeError", status, err)
|
|
}
|
|
assertNoSecret(t, "error", err.Error())
|
|
if _, ok := h.row(t); !ok {
|
|
t.Errorf("HTTP %d: connection cleared although Google did not revoke", status)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDisconnectNotConnected(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
if err := h.svc.Disconnect(context.Background()); !errors.Is(err, gcal.ErrNotConnected) {
|
|
t.Fatalf("Disconnect = %v, want ErrNotConnected", err)
|
|
}
|
|
if len(h.google.Revoked()) != 0 {
|
|
t.Error("revoke called with nothing connected")
|
|
}
|
|
}
|
|
|
|
func TestNewServiceRequiresConfig(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
pub, _ := url.Parse(publicURL)
|
|
valid := gcal.Config{
|
|
ClientID: "id", ClientSecret: "secret", PublicURL: pub, SecretKey: []byte("k"),
|
|
Endpoint: gcal.GoogleEndpoint, RevokeURL: gcal.GoogleRevokeURL, CalendarAPI: gcal.GoogleCalendarAPI,
|
|
}
|
|
if _, err := gcal.NewService(h.st, h.box, valid); err != nil {
|
|
t.Fatalf("valid config: %v", err)
|
|
}
|
|
for name, edit := range map[string]func(*gcal.Config){
|
|
"client ID": func(c *gcal.Config) { c.ClientID = "" },
|
|
"client secret": func(c *gcal.Config) { c.ClientSecret = "" },
|
|
"public URL": func(c *gcal.Config) { c.PublicURL = nil },
|
|
"secret key": func(c *gcal.Config) { c.SecretKey = nil },
|
|
"token URL": func(c *gcal.Config) { c.Endpoint.TokenURL = "" },
|
|
"revoke URL": func(c *gcal.Config) { c.RevokeURL = "" },
|
|
"Calendar API": func(c *gcal.Config) { c.CalendarAPI = "" },
|
|
} {
|
|
cfg := valid
|
|
edit(&cfg)
|
|
if _, err := gcal.NewService(h.st, h.box, cfg); err == nil {
|
|
t.Errorf("NewService accepted a config without the %s", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertCookieCleared(t *testing.T, rec *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
for _, c := range rec.Result().Cookies() {
|
|
if c.Name == "__Host-mealquest_google" && c.MaxAge < 0 {
|
|
return
|
|
}
|
|
}
|
|
t.Error("connect cookie not cleared")
|
|
}
|
|
|
|
// assertNoSecret fails when text holds the refresh token, the access token
|
|
// or the client secret.
|
|
func assertNoSecret(t *testing.T, what, text string) {
|
|
t.Helper()
|
|
for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} {
|
|
if strings.Contains(text, secret) {
|
|
t.Errorf("%s contains a secret: %s", what, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func headerText(h http.Header) string {
|
|
var b strings.Builder
|
|
for k, vs := range h {
|
|
b.WriteString(k + ": " + strings.Join(vs, ", ") + "\n")
|
|
}
|
|
return b.String()
|
|
}
|