gogcli/internal/cmd/gmail_watch_server_coverage_test.go
2026-01-09 10:09:53 +01:00

580 lines
17 KiB
Go

package cmd
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/gmail/v1"
"google.golang.org/api/option"
)
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
func TestGmailWatchServer_SendHook_TransportError(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
store, err := newGmailWatchStore("a@b.com")
if err != nil {
t.Fatalf("store: %v", err)
}
_ = store.Update(func(s *gmailWatchState) error { s.Account = "a@b.com"; return nil })
server := &gmailWatchServer{
cfg: gmailWatchServeConfig{
Account: "a@b.com",
HookURL: "https://example.com/hook",
},
store: store,
hookClient: &http.Client{
Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("dial failed")
}),
},
logf: func(string, ...any) {},
warnf: func(string, ...any) {},
}
err = server.sendHook(context.Background(), &gmailHookPayload{Source: "gmail", Account: "a@b.com", HistoryID: "1"})
if err == nil || !strings.Contains(err.Error(), "dial failed") {
t.Fatalf("expected transport error, got: %v", err)
}
state := store.Get()
if state.LastDeliveryStatus != "error" {
t.Fatalf("unexpected status: %q", state.LastDeliveryStatus)
}
if !strings.Contains(state.LastDeliveryStatusNote, "dial failed") {
t.Fatalf("unexpected note: %q", state.LastDeliveryStatusNote)
}
}
func TestGmailWatchServer_ServeHTTP_HandlePushError(t *testing.T) {
server := &gmailWatchServer{
cfg: gmailWatchServeConfig{Path: "/hook", SharedToken: "tok"},
store: &gmailWatchStore{state: gmailWatchState{HistoryID: "bad"}},
logf: func(string, ...any) {},
warnf: func(string, ...any) {},
}
push := pubsubPushEnvelope{}
push.Message.Data = base64.StdEncoding.EncodeToString([]byte(`{"historyId":"200"}`))
body, _ := json.Marshal(push)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/hook?token=tok", bytes.NewReader(body))
server.ServeHTTP(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("status: %d", rr.Code)
}
}
func TestGmailWatchServer_ServeHTTP_NoHook_Accepted(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
store, err := newGmailWatchStore("a@b.com")
if err != nil {
t.Fatalf("store: %v", err)
}
_ = store.Update(func(s *gmailWatchState) error {
s.Account = "a@b.com"
s.HistoryID = "100"
return nil
})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/gmail/v1/users/me/history"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"historyId": "200",
"history": []map[string]any{
{"messagesAdded": []map[string]any{{"message": map[string]any{"id": "m1"}}}},
},
})
return
case strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m1"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "m1",
"threadId": "t1",
"payload": map[string]any{"headers": []map[string]any{}},
})
return
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
gsvc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
server := &gmailWatchServer{
cfg: gmailWatchServeConfig{
Account: "a@b.com",
Path: "/hook",
SharedToken: "tok",
HistoryMax: 10,
},
store: store,
newService: func(context.Context, string) (*gmail.Service, error) { return gsvc, nil },
hookClient: srv.Client(),
logf: func(string, ...any) {},
warnf: func(string, ...any) {},
}
push := pubsubPushEnvelope{}
push.Message.Data = base64.StdEncoding.EncodeToString([]byte(`{"emailAddress":"a@b.com","historyId":"200"}`))
body, _ := json.Marshal(push)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/hook?token=tok", bytes.NewReader(body))
server.ServeHTTP(rr, req)
if rr.Code != http.StatusAccepted {
t.Fatalf("status: %d", rr.Code)
}
}
func TestGmailWatchServer_ServeHTTP_HookSuccess(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
store, err := newGmailWatchStore("a@b.com")
if err != nil {
t.Fatalf("store: %v", err)
}
_ = store.Update(func(s *gmailWatchState) error {
s.Account = "a@b.com"
s.HistoryID = "100"
return nil
})
gmailSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/gmail/v1/users/me/history"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"historyId": "200",
"history": []map[string]any{
{"messagesAdded": []map[string]any{{"message": map[string]any{"id": "m1"}}}},
},
})
return
case strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m1"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "m1",
"threadId": "t1",
"payload": map[string]any{"headers": []map[string]any{}},
})
return
default:
http.NotFound(w, r)
}
}))
defer gmailSrv.Close()
gsvc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(gmailSrv.Client()),
option.WithEndpoint(gmailSrv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
hookSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer hookSrv.Close()
server := &gmailWatchServer{
cfg: gmailWatchServeConfig{
Account: "a@b.com",
Path: "/hook",
SharedToken: "tok",
HookURL: hookSrv.URL,
HistoryMax: 10,
},
store: store,
newService: func(context.Context, string) (*gmail.Service, error) { return gsvc, nil },
hookClient: hookSrv.Client(),
logf: func(string, ...any) {},
warnf: func(string, ...any) {},
}
push := pubsubPushEnvelope{}
push.Message.Data = base64.StdEncoding.EncodeToString([]byte(`{"emailAddress":"a@b.com","historyId":"200"}`))
body, _ := json.Marshal(push)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/hook?token=tok", bytes.NewReader(body))
server.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status: %d", rr.Code)
}
}
func TestGmailWatchServer_HandlePush_NewServiceError(t *testing.T) {
store := &gmailWatchStore{state: gmailWatchState{HistoryID: "100"}}
server := &gmailWatchServer{
cfg: gmailWatchServeConfig{Account: "a@b.com"},
store: store,
newService: func(context.Context, string) (*gmail.Service, error) {
return nil, errors.New("service down")
},
logf: func(string, ...any) {},
warnf: func(string, ...any) {},
}
if _, err := server.handlePush(context.Background(), gmailPushPayload{HistoryID: "200"}); err == nil {
t.Fatalf("expected error")
}
}
func TestGmailWatchServer_HandlePush_HistoryError(t *testing.T) {
store := &gmailWatchStore{state: gmailWatchState{HistoryID: "100"}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/gmail/v1/users/me/history") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{"code": http.StatusInternalServerError, "message": "oops"},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
gsvc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
server := &gmailWatchServer{
cfg: gmailWatchServeConfig{Account: "a@b.com", HistoryMax: 10},
store: store,
newService: func(context.Context, string) (*gmail.Service, error) { return gsvc, nil },
logf: func(string, ...any) {},
warnf: func(string, ...any) {},
}
if _, err := server.handlePush(context.Background(), gmailPushPayload{HistoryID: "200"}); err == nil {
t.Fatalf("expected history error")
}
}
func TestGmailWatchServer_HandlePush_StaleHistory(t *testing.T) {
server := &gmailWatchServer{
cfg: gmailWatchServeConfig{Account: "a@b.com"},
store: &gmailWatchStore{state: gmailWatchState{HistoryID: "100"}},
logf: func(string, ...any) {},
warnf: func(string, ...any) {},
}
if _, err := server.handlePush(context.Background(), gmailPushPayload{HistoryID: "99"}); !errors.Is(err, errNoNewMessages) {
t.Fatalf("expected no new messages, got %v", err)
}
}
func TestGmailWatchServer_HandlePush_FetchMessagesError(t *testing.T) {
store := &gmailWatchStore{state: gmailWatchState{HistoryID: "100"}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/gmail/v1/users/me/history"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"historyId": "200",
"history": []map[string]any{
{"messagesAdded": []map[string]any{{"message": map[string]any{"id": "m1"}}}},
},
})
return
case strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m1"):
w.WriteHeader(http.StatusInternalServerError)
return
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
gsvc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
server := &gmailWatchServer{
cfg: gmailWatchServeConfig{Account: "a@b.com", HistoryMax: 10},
store: store,
newService: func(context.Context, string) (*gmail.Service, error) { return gsvc, nil },
logf: func(string, ...any) {},
warnf: func(string, ...any) {},
}
if _, err := server.handlePush(context.Background(), gmailPushPayload{HistoryID: "200"}); err == nil {
t.Fatalf("expected fetch error")
}
}
func TestGmailWatchServer_HandlePush_UpdateError(t *testing.T) {
store := &gmailWatchStore{state: gmailWatchState{HistoryID: "100"}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/gmail/v1/users/me/history"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"historyId": "200",
"history": []map[string]any{
{"messagesAdded": []map[string]any{{"message": map[string]any{"id": "m1"}}}},
},
})
return
case strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m1"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "m1",
"threadId": "t1",
"snippet": "hi",
"payload": map[string]any{"headers": []map[string]any{}},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
gsvc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
server := &gmailWatchServer{
cfg: gmailWatchServeConfig{Account: "a@b.com", HistoryMax: 10},
store: store,
newService: func(context.Context, string) (*gmail.Service, error) { return gsvc, nil },
logf: func(string, ...any) {},
warnf: func(string, ...any) {},
}
got, err := server.handlePush(context.Background(), gmailPushPayload{HistoryID: "200"})
if err != nil {
t.Fatalf("handlePush: %v", err)
}
if got == nil || got.HistoryID == "" {
t.Fatalf("expected payload")
}
}
func TestGmailWatchServer_HandlePush_UpdateError_InvalidHistoryID(t *testing.T) {
store := &gmailWatchStore{state: gmailWatchState{HistoryID: "100"}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/gmail/v1/users/me/history") {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"historyId": "0",
"history": []map[string]any{},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
gsvc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
server := &gmailWatchServer{
cfg: gmailWatchServeConfig{Account: "a@b.com", HistoryMax: 10},
store: store,
newService: func(context.Context, string) (*gmail.Service, error) { return gsvc, nil },
logf: func(string, ...any) {},
warnf: func(string, ...any) {},
}
got, err := server.handlePush(context.Background(), gmailPushPayload{HistoryID: "bad"})
if err != nil {
t.Fatalf("handlePush: %v", err)
}
if got == nil {
t.Fatalf("expected payload")
}
}
func TestGmailWatchServer_ResyncHistory_ListError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{"code": http.StatusInternalServerError, "message": "boom"},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
gsvc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
server := &gmailWatchServer{
cfg: gmailWatchServeConfig{ResyncMax: 10},
store: &gmailWatchStore{},
logf: func(string, ...any) {},
warnf: func(string, ...any) {},
}
if _, err := server.resyncHistory(context.Background(), gsvc, "200", ""); err == nil {
t.Fatalf("expected resync error")
}
}
func TestGmailWatchServer_ResyncHistory_FetchMessagesError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m1") && r.Method == http.MethodGet:
w.WriteHeader(http.StatusInternalServerError)
return
case strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages") && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"messages": []map[string]any{{"id": "m1"}},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
gsvc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
server := &gmailWatchServer{
cfg: gmailWatchServeConfig{ResyncMax: 10},
store: &gmailWatchStore{},
logf: func(string, ...any) {},
warnf: func(string, ...any) {},
}
if _, err := server.resyncHistory(context.Background(), gsvc, "200", ""); err == nil {
t.Fatalf("expected error")
}
}
func TestGmailWatchServer_ResyncHistory_UpdateError_InvalidHistoryID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages") && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"messages": []map[string]any{{"id": "m1"}},
})
return
}
if strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m1") && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "m1",
"threadId": "t1",
"payload": map[string]any{"headers": []map[string]any{}},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
gsvc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
server := &gmailWatchServer{
cfg: gmailWatchServeConfig{Account: "a@b.com", ResyncMax: 10},
store: &gmailWatchStore{state: gmailWatchState{HistoryID: "100"}},
logf: func(string, ...any) {},
warnf: func(string, ...any) {},
}
if _, err := server.resyncHistory(context.Background(), gsvc, "bad", ""); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
type errReadCloser struct{}
func (errReadCloser) Read([]byte) (int, error) { return 0, errors.New("read error") }
func (errReadCloser) Close() error { return nil }
func TestParsePubSubPush_ReadError(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", errReadCloser{})
if _, err := parsePubSubPush(req); err == nil {
t.Fatalf("expected error")
}
}
func TestGmailWatchServer_OIDCAudience_Explicit(t *testing.T) {
s := &gmailWatchServer{cfg: gmailWatchServeConfig{OIDCAudience: "https://example.com/hook"}}
r := httptest.NewRequest(http.MethodPost, "http://ignored/hook", nil)
if got := s.oidcAudience(r); got != "https://example.com/hook" {
t.Fatalf("unexpected audience: %q", got)
}
}