2026-08-18 13:28:44 +02:00
|
|
|
package httpapi
|
|
|
|
|
|
|
|
|
|
import (
|
2026-09-04 08:57:07 +02:00
|
|
|
"encoding/json"
|
|
|
|
|
"io"
|
2026-08-18 13:28:44 +02:00
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
2026-08-18 13:43:48 +02:00
|
|
|
"net/url"
|
2026-09-04 08:57:07 +02:00
|
|
|
"strings"
|
2026-08-18 13:28:44 +02:00
|
|
|
"testing"
|
2026-08-18 13:43:48 +02:00
|
|
|
"time"
|
2026-08-18 13:28:44 +02:00
|
|
|
|
|
|
|
|
"github.com/kristianwind/verdande/internal/config"
|
2026-09-04 08:57:07 +02:00
|
|
|
"github.com/kristianwind/verdande/internal/store"
|
2026-08-18 13:28:44 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// A Gmail failure has to survive being reported.
|
|
|
|
|
//
|
|
|
|
|
// It used to be hidden twice over. The handler answered 502 with the code
|
|
|
|
|
// `internal_error`, which the interface maps to "something went wrong" — so
|
|
|
|
|
// Google's own words, the only thing that says what to do, were replaced by a
|
|
|
|
|
// sentence that says nothing. And `writeError` records nothing, so the error log
|
|
|
|
|
// was empty: a person clicked Fetch now, read a generic failure, and there was
|
|
|
|
|
// nowhere to look it up afterwards.
|
|
|
|
|
func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
|
|
|
|
|
// A Gmail that refuses, the way an expired grant does.
|
|
|
|
|
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
|
|
|
|
|
}))
|
|
|
|
|
defer google.Close()
|
|
|
|
|
|
|
|
|
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
|
|
|
|
cfg.GmailClientID = "test-client"
|
|
|
|
|
cfg.GmailClientSecret = "test-secret"
|
2026-08-20 07:01:14 +02:00
|
|
|
cfg.GoogleTokenURL = google.URL + "/token"
|
2026-08-18 13:43:48 +02:00
|
|
|
cfg.GmailAPIURL = google.URL
|
2026-08-18 13:28:44 +02:00
|
|
|
})
|
|
|
|
|
ts.bootstrap(t)
|
|
|
|
|
|
|
|
|
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
// Connected, with a refresh token that will be refused.
|
2026-08-18 22:59:40 +02:00
|
|
|
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
|
|
|
|
|
UserID: user.ID, Kind: "gmail", RefreshToken: "spent", Trigger: "starred",
|
2026-08-18 13:28:44 +02:00
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
|
2026-08-18 19:17:49 +02:00
|
|
|
if resp.StatusCode != StatusUpstreamRefused {
|
2026-08-18 13:28:44 +02:00
|
|
|
t.Fatalf("sync: status %d, want 502 — this is Gmail saying no, not this server breaking",
|
|
|
|
|
resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
if body["code"] != "gmail_failed" {
|
|
|
|
|
t.Errorf("code = %v, want gmail_failed — `internal_error` is mapped to a generic "+
|
|
|
|
|
"sentence, which throws the diagnosis away", body["code"])
|
|
|
|
|
}
|
|
|
|
|
if msg, _ := body["error"].(string); msg == "" {
|
|
|
|
|
t.Error("the message is empty; Gmail's own words are the whole diagnosis")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// And it is in the error log, which is the point of having one: a sync that has
|
|
|
|
|
// been failing for a week is invisible otherwise.
|
|
|
|
|
_, logged := ts.do(t, "GET", "/api/v1/errors", nil)
|
|
|
|
|
rows, _ := logged["errors"].([]any)
|
|
|
|
|
if len(rows) == 0 {
|
|
|
|
|
t.Fatal("nothing was recorded; the failure is as invisible as it was before")
|
|
|
|
|
}
|
|
|
|
|
row := rows[0].(map[string]any)
|
|
|
|
|
if row["what"] != "sync gmail" {
|
|
|
|
|
t.Errorf("what = %v, want \"sync gmail\"", row["what"])
|
|
|
|
|
}
|
2026-08-18 19:17:49 +02:00
|
|
|
if row["status"] != float64(StatusUpstreamRefused) {
|
2026-08-18 13:28:44 +02:00
|
|
|
t.Errorf("status = %v, want 502", row["status"])
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-18 13:43:48 +02:00
|
|
|
|
|
|
|
|
// A slow mailbox must not hold the request open until something in front of this
|
|
|
|
|
// server gives up on it.
|
|
|
|
|
//
|
|
|
|
|
// This is what was actually happening in production: each Gmail call has its own
|
|
|
|
|
// thirty-second timeout, and the sync makes up to twenty-five of them one after the
|
|
|
|
|
// other. Cloudflare stopped waiting at a hundred seconds and answered with its own
|
|
|
|
|
// 502 page — HTML, so the browser got no code, no message and no error-log row, and
|
|
|
|
|
// showed "something went wrong". The handler had never returned.
|
|
|
|
|
func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
|
|
|
|
// A Gmail that never answers.
|
|
|
|
|
blocked := make(chan struct{})
|
|
|
|
|
defer close(blocked)
|
|
|
|
|
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
select {
|
|
|
|
|
case <-blocked:
|
|
|
|
|
case <-r.Context().Done():
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
defer google.Close()
|
|
|
|
|
|
|
|
|
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
|
|
|
|
cfg.GmailClientID = "test-client"
|
|
|
|
|
cfg.GmailClientSecret = "test-secret"
|
2026-08-20 07:01:14 +02:00
|
|
|
cfg.GoogleTokenURL = google.URL + "/token"
|
2026-08-18 13:43:48 +02:00
|
|
|
cfg.GmailAPIURL = google.URL
|
|
|
|
|
// A second rather than the real twenty-five: what is being tested is that a
|
|
|
|
|
// budget is applied at all, and spending the production one on every run
|
|
|
|
|
// would put half a minute into the suite to learn the same thing.
|
|
|
|
|
cfg.GmailSyncBudget = time.Second
|
|
|
|
|
})
|
|
|
|
|
ts.bootstrap(t)
|
|
|
|
|
|
|
|
|
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
// A token that has not expired, so the run goes straight to the message list
|
|
|
|
|
// rather than stopping at a refresh.
|
2026-08-18 22:59:40 +02:00
|
|
|
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
|
|
|
|
|
UserID: user.ID, Kind: "gmail", RefreshToken: "r", AccessToken: "a",
|
|
|
|
|
Trigger: "starred", ExpiresAt: time.Now().Add(time.Hour),
|
2026-08-18 13:43:48 +02:00
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Its own client, because the shared one gives up after ten seconds — which is
|
|
|
|
|
// shorter than the budget being tested and would prove nothing either way.
|
|
|
|
|
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/gmail/sync", nil)
|
|
|
|
|
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
|
|
|
|
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
|
|
|
|
req.AddCookie(c)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
started := time.Now()
|
|
|
|
|
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("the request never came back (%v after %s); the sync has no budget of "+
|
|
|
|
|
"its own, and whatever sits in front of this server answers for it",
|
|
|
|
|
err, time.Since(started).Round(time.Second))
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
took := time.Since(started)
|
|
|
|
|
|
|
|
|
|
// Cloudflare stops waiting at a hundred seconds. Anything close to that is the
|
|
|
|
|
// bug, whatever status eventually arrives.
|
|
|
|
|
// Comfortably over the one-second budget and far under any proxy's patience.
|
|
|
|
|
if took > 10*time.Second {
|
|
|
|
|
t.Errorf("the sync took %s; the budget is not being applied",
|
|
|
|
|
took.Round(time.Second))
|
|
|
|
|
}
|
2026-08-18 19:17:49 +02:00
|
|
|
if resp.StatusCode >= 500 && resp.StatusCode != StatusUpstreamRefused {
|
2026-08-18 13:43:48 +02:00
|
|
|
t.Errorf("status %d — a slow mailbox is not this server breaking", resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 08:57:07 +02:00
|
|
|
// A message that becomes a task has its star taken off — the write that the whole
|
|
|
|
|
// gmail.modify scope is for. The mail's own inbox empties as the tasks are made,
|
|
|
|
|
// the way the IMAP side unflags what it imports.
|
|
|
|
|
func TestASyncedGmailMessageIsUnstarred(t *testing.T) {
|
|
|
|
|
var unstarred struct {
|
|
|
|
|
hit bool
|
|
|
|
|
body struct {
|
|
|
|
|
IDs []string `json:"ids"`
|
|
|
|
|
RemoveLabelIDs []string `json:"removeLabelIds"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
switch {
|
|
|
|
|
case r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/messages/batchModify"):
|
|
|
|
|
unstarred.hit = true
|
|
|
|
|
raw, _ := io.ReadAll(r.Body)
|
|
|
|
|
_ = json.Unmarshal(raw, &unstarred.body)
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
case strings.Contains(r.URL.Path, "/messages/") && r.URL.Query().Get("format") == "metadata":
|
|
|
|
|
writeTestJSON(w, map[string]any{
|
|
|
|
|
"id": "m1", "threadId": "t1", "snippet": "kort tekst",
|
|
|
|
|
"payload": map[string]any{"headers": []map[string]string{
|
|
|
|
|
{"name": "From", "value": "Anders <anders@example.dk>"},
|
|
|
|
|
{"name": "Subject", "value": "Møde på fredag"},
|
|
|
|
|
}},
|
|
|
|
|
})
|
|
|
|
|
case strings.HasSuffix(r.URL.Path, "/messages"):
|
|
|
|
|
writeTestJSON(w, map[string]any{"messages": []map[string]string{{"id": "m1"}}})
|
|
|
|
|
default:
|
|
|
|
|
http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, http.StatusNotFound)
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
defer google.Close()
|
|
|
|
|
|
|
|
|
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
|
|
|
|
cfg.GmailClientID = "test-client"
|
|
|
|
|
cfg.GmailClientSecret = "test-secret"
|
|
|
|
|
cfg.GoogleTokenURL = google.URL + "/token"
|
|
|
|
|
cfg.GmailAPIURL = google.URL
|
|
|
|
|
})
|
|
|
|
|
ts.bootstrap(t)
|
|
|
|
|
|
|
|
|
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
// A live access token, so the run goes straight to the list without a refresh.
|
|
|
|
|
if err := ts.db.SaveMailbox(t.Context(), &store.Mailbox{
|
|
|
|
|
UserID: user.ID, Kind: "gmail", RefreshToken: "r", AccessToken: "a",
|
|
|
|
|
Trigger: "starred", ExpiresAt: time.Now().Add(time.Hour),
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
t.Fatalf("sync: status %d, want 200", resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
if created, _ := body["created"].(float64); created != 1 {
|
|
|
|
|
t.Fatalf("created = %v, want 1", body["created"])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !unstarred.hit {
|
|
|
|
|
t.Fatal("the message was never unstarred; its star is the one thing modify is for")
|
|
|
|
|
}
|
|
|
|
|
if strings.Join(unstarred.body.IDs, ",") != "m1" {
|
|
|
|
|
t.Errorf("unstarred ids = %v, want [m1]", unstarred.body.IDs)
|
|
|
|
|
}
|
|
|
|
|
if strings.Join(unstarred.body.RemoveLabelIDs, ",") != "STARRED" {
|
|
|
|
|
t.Errorf("removed = %v, want [STARRED]", unstarred.body.RemoveLabelIDs)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writeTestJSON(w http.ResponseWriter, v any) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 13:43:48 +02:00
|
|
|
func mustParse(t *testing.T, raw string) *url.URL {
|
|
|
|
|
t.Helper()
|
|
|
|
|
u, err := url.Parse(raw)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return u
|
|
|
|
|
}
|