Samme registrering som Gmail, et andet scope og en anden redirect-URI. Google udsteder en refresh-token pr. godkendelse og holder op til hundrede i live pr. konto, hver med sine egne scopes — så en forbundet kalender rører ikke en Gmail, der virker, og at afbryde den ene tager ikke den anden med. Callbacken har sin egen sti. Én callback, der ud fra gemt tilstand afgjorde, hvilket af de to flows der kom tilbage, ville lægge valget af *hvilket token-lager der skrives* bag et opslag — og går det galt, går det galt ved at overskrive en virkende Gmail-forbindelse med kalender-tokens. Begivenhederne caches i et vindue på 90 dage tilbage og et år frem. Et månedsgitter bladrer, og et kald til Google pr. piletast er en kalender, der er langsommere end den kalender, den viser. Vinduet har en kant, og endpointet siger hvor den er: et tomt svar uden forklaring er en kalender, der lydløst påstår, at en dag er fri.
472 lines
17 KiB
Go
472 lines
17 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kristianwind/verdande/internal/config"
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
)
|
|
|
|
// fakeCalendarGoogle stands in for the parts of Google this feature touches: the
|
|
// token endpoint, the calendar list and one calendar's events.
|
|
//
|
|
// There is no other way to exercise any of it. The real thing needs a registered
|
|
// OAuth client, an account and somebody's consent, and none of the three can be had
|
|
// inside a test — so the shapes Google returns are written down and the behaviour
|
|
// is checked against them, the way internal/imap is checked against go-imap's own
|
|
// in-process server.
|
|
type fakeCalendarGoogle struct {
|
|
*httptest.Server
|
|
// refreshes counts token refreshes, so a test can prove one is not made for a
|
|
// token that has not expired.
|
|
refreshes int
|
|
// refuse makes every API call answer 401, which is what a revoked grant does.
|
|
refuse bool
|
|
events map[string][]map[string]any
|
|
}
|
|
|
|
func newFakeCalendarGoogle(t *testing.T) *fakeCalendarGoogle {
|
|
t.Helper()
|
|
g := &fakeCalendarGoogle{events: map[string][]map[string]any{}}
|
|
g.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch {
|
|
case r.URL.Path == "/token":
|
|
g.refreshes++
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"access_token": "ya29.fresh", "refresh_token": "1//0-refresh",
|
|
"expires_in": 3600,
|
|
})
|
|
case g.refuse:
|
|
http.Error(w, `{"error":"invalid_credentials"}`, http.StatusUnauthorized)
|
|
case strings.HasPrefix(r.URL.Path, "/users/me/calendarList"):
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{
|
|
{"id": "kw@nolimit.dk", "summary": "kw@nolimit.dk", "primary": true,
|
|
"accessRole": "owner", "backgroundColor": "#3f51b5",
|
|
"timeZone": "Europe/Copenhagen"},
|
|
{"id": "fam", "summary": "Familien", "accessRole": "reader",
|
|
"backgroundColor": "#0b8043"},
|
|
}})
|
|
case strings.HasPrefix(r.URL.Path, "/calendars/"):
|
|
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/events")
|
|
items := g.events[id]
|
|
if items == nil {
|
|
items = []map[string]any{}
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"items": items})
|
|
default:
|
|
http.Error(w, "unexpected "+r.URL.Path, http.StatusNotFound)
|
|
}
|
|
}))
|
|
t.Cleanup(g.Close)
|
|
return g
|
|
}
|
|
|
|
// connectedCalendar returns a server with a calendar account already connected —
|
|
// the state the OAuth callback leaves behind, without the consent screen nothing
|
|
// can automate.
|
|
func connectedCalendar(t *testing.T, g *fakeCalendarGoogle, tune func(*config.Config)) (*testServer, *store.User) {
|
|
t.Helper()
|
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
|
cfg.GmailClientID = "test-client"
|
|
cfg.GmailClientSecret = "test-secret"
|
|
cfg.GoogleTokenURL = g.URL + "/token"
|
|
cfg.CalendarAPIURL = g.URL
|
|
if tune != nil {
|
|
tune(cfg)
|
|
}
|
|
})
|
|
ts.bootstrap(t)
|
|
|
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
account := &store.CalendarAccount{
|
|
UserID: user.ID, Provider: "google", RefreshToken: "1//0-refresh",
|
|
}
|
|
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return ts, user
|
|
}
|
|
|
|
// The scope on the consent screen is what somebody agrees to. It has to be the
|
|
// read-only one, and the redirect has to be the calendar's own — sharing Gmail's
|
|
// would put "which token store do I write" behind a lookup.
|
|
func TestAuthorizeAsksForReadOnlyCalendar(t *testing.T) {
|
|
g := newFakeCalendarGoogle(t)
|
|
ts, _ := connectedCalendar(t, g, nil)
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/calendar/authorize", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
|
}
|
|
url, _ := body["url"].(string)
|
|
for _, want := range []string{
|
|
"scope=" + "https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly",
|
|
"%2Foauth%2Fcalendar%2Fcallback",
|
|
"access_type=offline",
|
|
"prompt=consent",
|
|
} {
|
|
if !strings.Contains(url, want) {
|
|
t.Errorf("the authorisation URL is missing %q:\n%s", want, url)
|
|
}
|
|
}
|
|
if strings.Contains(url, "calendar.events&") || strings.Contains(url, "auth%2Fcalendar&") {
|
|
t.Errorf("a writing scope was asked for:\n%s", url)
|
|
}
|
|
}
|
|
|
|
// Without a registered client there is nothing to connect through, and the answer
|
|
// has to say that rather than "that already exists".
|
|
func TestAuthorizeWithoutAClientSaysSo(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/calendar/authorize", nil)
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("status %d, want 409", resp.StatusCode)
|
|
}
|
|
if body["code"] != CodeGmailNotConfigured {
|
|
t.Errorf("code = %v, want %q", body["code"], CodeGmailNotConfigured)
|
|
}
|
|
}
|
|
|
|
// The whole path, minus the consent screen: sync fetches the list, refreshes the
|
|
// events of what was chosen, and the grid endpoint answers with them.
|
|
func TestSyncFetchesTheChosenCalendarsAndTheGridReadsThem(t *testing.T) {
|
|
g := newFakeCalendarGoogle(t)
|
|
g.events["fam"] = []map[string]any{
|
|
{"id": "e1", "summary": "Bestyrelsesmøde", "htmlLink": "https://calendar.google.com/e1",
|
|
"start": map[string]string{"dateTime": "2026-08-20T14:00:00+02:00"},
|
|
"end": map[string]string{"dateTime": "2026-08-20T15:30:00+02:00"}},
|
|
}
|
|
g.events["kw@nolimit.dk"] = []map[string]any{
|
|
{"id": "e2", "summary": "Skal ikke med",
|
|
"start": map[string]string{"date": "2026-08-21"},
|
|
"end": map[string]string{"date": "2026-08-22"}},
|
|
}
|
|
ts, user := connectedCalendar(t, g, nil)
|
|
|
|
// The list arrives on the first look at the settings page, which is what
|
|
// somebody would be doing at this point.
|
|
resp, body := ts.do(t, "GET", "/api/v1/calendar", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
|
}
|
|
if body["connected"] != true {
|
|
t.Fatalf("not reported as connected: %v", body)
|
|
}
|
|
if body["read_only"] != true {
|
|
t.Error("the connection did not say it is read-only; an interface that looks " +
|
|
"editable is one somebody will try to edit")
|
|
}
|
|
// The primary calendar's id is the address, which is how the account names
|
|
// itself without asking for a profile scope as well.
|
|
if body["account"] != "kw@nolimit.dk" {
|
|
t.Errorf("account = %v", body["account"])
|
|
}
|
|
|
|
list, _ := body["calendars"].([]any)
|
|
if len(list) != 2 {
|
|
t.Fatalf("got %d calendars, want 2: %v", len(list), body["calendars"])
|
|
}
|
|
var famID string
|
|
for _, raw := range list {
|
|
c := raw.(map[string]any)
|
|
if c["shown"] == true {
|
|
t.Errorf("%v was shown without anybody choosing it", c["name"])
|
|
}
|
|
if c["remote_id"] == "fam" {
|
|
famID = c["id"].(string)
|
|
}
|
|
}
|
|
|
|
// Nothing is chosen, so a sync writes nothing: an account with a dozen shared
|
|
// calendars must not fill somebody's grid the moment it is connected.
|
|
resp, body = ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
|
if resp.StatusCode != http.StatusOK || body["events"] != float64(0) {
|
|
t.Fatalf("an unchosen calendar was fetched: %d %v", resp.StatusCode, body)
|
|
}
|
|
|
|
resp, _ = ts.do(t, "PUT", "/api/v1/calendar/calendars",
|
|
map[string]any{"shown": []string{famID}})
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("choosing a calendar: status %d", resp.StatusCode)
|
|
}
|
|
|
|
resp, body = ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
|
|
}
|
|
if body["events"] != float64(1) {
|
|
t.Fatalf("events = %v, want 1 — only the chosen calendar", body["events"])
|
|
}
|
|
|
|
resp, body = ts.do(t, "GET",
|
|
"/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("events: status %d, body %v", resp.StatusCode, body)
|
|
}
|
|
events, _ := body["events"].([]any)
|
|
if len(events) != 1 {
|
|
t.Fatalf("got %d events, want 1: %v", len(events), body["events"])
|
|
}
|
|
event := events[0].(map[string]any)
|
|
if event["summary"] != "Bestyrelsesmøde" || event["start_day"] != "2026-08-20" {
|
|
t.Errorf("the event came back as %v", event)
|
|
}
|
|
// The calendar's colour rides along, or the chip has to ask for it per row.
|
|
if event["colour"] != "#0b8043" {
|
|
t.Errorf("colour = %v; two calendars in one grid are told apart by it", event["colour"])
|
|
}
|
|
// The window verdande holds is stated, so a grid paged past it can say so
|
|
// rather than showing an empty month it cannot explain.
|
|
if body["from"] == "" || body["to"] == "" {
|
|
t.Error("the answer does not say which window it covers")
|
|
}
|
|
|
|
// And it is one person's calendar. Somebody else on the same instance sees
|
|
// nothing of it.
|
|
other := ts.newUser(t, "andreas@example.dk", "Andreas")
|
|
_, body = other.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
|
if events, _ := body["events"].([]any); len(events) != 0 {
|
|
t.Errorf("somebody else's calendar was visible: %v", body["events"])
|
|
}
|
|
_ = user
|
|
}
|
|
|
|
// A token that has not expired must not be traded in for another. Refreshing on
|
|
// every run burns the quota and makes the failure mode of a bad refresh token
|
|
// arrive on every sweep rather than once an hour.
|
|
func TestAFreshTokenIsNotRefreshed(t *testing.T) {
|
|
g := newFakeCalendarGoogle(t)
|
|
ts, user := connectedCalendar(t, g, nil)
|
|
|
|
account, err := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
account.AccessToken = "ya29.still-good"
|
|
account.ExpiresAt = time.Now().Add(time.Hour)
|
|
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil); resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
|
|
}
|
|
if g.refreshes != 0 {
|
|
t.Errorf("the token was refreshed %d times while it was still good", g.refreshes)
|
|
}
|
|
|
|
// And an expired one is.
|
|
account.ExpiresAt = time.Now().Add(-time.Minute)
|
|
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil); resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("sync: status %d, body %v", resp.StatusCode, body)
|
|
}
|
|
if g.refreshes != 1 {
|
|
t.Errorf("an expired token was refreshed %d times, want 1", g.refreshes)
|
|
}
|
|
}
|
|
|
|
// Access revoked at Google's end is a connection that has ended, not a failure to
|
|
// retry. Polling it every fifteen minutes for ever is a week of log lines and a
|
|
// connection the person believes they removed.
|
|
func TestRevokedAccessDisconnects(t *testing.T) {
|
|
g := newFakeCalendarGoogle(t)
|
|
ts, user := connectedCalendar(t, g, nil)
|
|
|
|
account, _ := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
|
account.AccessToken = "ya29.revoked"
|
|
account.ExpiresAt = time.Now().Add(time.Hour)
|
|
if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
g.refuse = true
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
|
if resp.StatusCode != StatusUpstreamRefused {
|
|
t.Fatalf("status %d, want %d — Google saying no is not this server breaking",
|
|
resp.StatusCode, StatusUpstreamRefused)
|
|
}
|
|
if body["code"] != CodeCalendarFailed {
|
|
t.Errorf("code = %v, want %q — `internal_error` is shown as a generic sentence, "+
|
|
"which throws the diagnosis away", body["code"], CodeCalendarFailed)
|
|
}
|
|
|
|
gone, err := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gone != nil {
|
|
t.Error("the connection is still there after Google refused the token")
|
|
}
|
|
|
|
// And the failure 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")
|
|
}
|
|
if row := rows[0].(map[string]any); row["what"] != "sync calendar" {
|
|
t.Errorf("what = %v, want \"sync calendar\"", row["what"])
|
|
}
|
|
}
|
|
|
|
// Disconnecting takes the copy with it. None of it is the person's own work, and
|
|
// a calendar left behind after the account is gone is a frozen lie about a day.
|
|
func TestDisconnectingRemovesTheEvents(t *testing.T) {
|
|
g := newFakeCalendarGoogle(t)
|
|
g.events["fam"] = []map[string]any{
|
|
{"id": "e1", "summary": "Møde",
|
|
"start": map[string]string{"date": "2026-08-20"},
|
|
"end": map[string]string{"date": "2026-08-21"}},
|
|
}
|
|
ts, user := connectedCalendar(t, g, nil)
|
|
|
|
ts.do(t, "GET", "/api/v1/calendar", nil)
|
|
account, _ := ts.db.CalendarAccountFor(t.Context(), user.ID, "google")
|
|
calendars, _ := ts.db.Calendars(t.Context(), account.ID)
|
|
var famID string
|
|
for _, c := range calendars {
|
|
if c.RemoteID == "fam" {
|
|
famID = c.ID
|
|
}
|
|
}
|
|
ts.do(t, "PUT", "/api/v1/calendar/calendars", map[string]any{"shown": []string{famID}})
|
|
ts.do(t, "POST", "/api/v1/calendar/sync", nil)
|
|
|
|
if resp, _ := ts.do(t, "DELETE", "/api/v1/calendar", nil); resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("disconnect: status %d", resp.StatusCode)
|
|
}
|
|
|
|
_, body := ts.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
|
if events, _ := body["events"].([]any); len(events) != 0 {
|
|
t.Errorf("events survived the disconnect: %v", body["events"])
|
|
}
|
|
_, body = ts.do(t, "GET", "/api/v1/calendar", nil)
|
|
if body["connected"] != false {
|
|
t.Error("still reported as connected")
|
|
}
|
|
}
|
|
|
|
// A range that is not a date has to be refused, not guessed at. An empty `from`
|
|
// compared as a string matches everything before it, which is a grid quietly
|
|
// answering a question nobody asked.
|
|
func TestEventsRefuseARangeThatIsNotADate(t *testing.T) {
|
|
g := newFakeCalendarGoogle(t)
|
|
ts, _ := connectedCalendar(t, g, nil)
|
|
|
|
for _, query := range []string{
|
|
"", "?from=2026-08-01", "?from=&to=2026-08-31",
|
|
"?from=i+morgen&to=2026-08-31", "?from=2026-08-01&to=2026-13-40",
|
|
} {
|
|
resp, body := ts.do(t, "GET", "/api/v1/calendar/events"+query, nil)
|
|
if resp.StatusCode != http.StatusUnprocessableEntity {
|
|
t.Errorf("%q: status %d, want 422 (%v)", query, resp.StatusCode, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A person with no calendar connected still opens the view. It has to answer, and
|
|
// answer emptily, rather than fail — the grid is also verdande's own tasks, and
|
|
// they must not disappear because Google is not set up.
|
|
func TestTheGridAnswersWithNoCalendarConnected(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
resp, body := ts.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
|
}
|
|
if events, ok := body["events"].([]any); !ok || len(events) != 0 {
|
|
t.Errorf("events = %v, want an empty list rather than null", body["events"])
|
|
}
|
|
|
|
resp, body = ts.do(t, "GET", "/api/v1/calendar", nil)
|
|
if resp.StatusCode != http.StatusOK || body["connected"] != false {
|
|
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
|
}
|
|
if body["has_client"] != false {
|
|
t.Error("an instance with no OAuth registration claimed to have one; the page " +
|
|
"would offer a button that answers 409")
|
|
}
|
|
// Spelled out even when nothing is connected: it is what has to be registered
|
|
// with Google, and it is the single most likely thing to be wrong.
|
|
if uri, _ := body["redirect_uri"].(string); !strings.HasSuffix(uri, "/oauth/calendar/callback") {
|
|
t.Errorf("redirect_uri = %q", uri)
|
|
}
|
|
}
|
|
|
|
// A slow Google must not hold the request open until something in front of this
|
|
// server gives up on it and answers with its own HTML.
|
|
func TestASlowCalendarDoesNotHangTheRequest(t *testing.T) {
|
|
blocked := make(chan struct{})
|
|
defer close(blocked)
|
|
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
select {
|
|
case <-blocked:
|
|
case <-r.Context().Done():
|
|
}
|
|
}))
|
|
defer slow.Close()
|
|
|
|
ts := newTestServerWith(t, func(cfg *config.Config) {
|
|
cfg.GmailClientID = "test-client"
|
|
cfg.GmailClientSecret = "test-secret"
|
|
cfg.GoogleTokenURL = slow.URL + "/token"
|
|
cfg.CalendarAPIURL = slow.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.CalendarSyncBudget = time.Second
|
|
})
|
|
ts.bootstrap(t)
|
|
|
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ts.db.SaveCalendarAccount(t.Context(), &store.CalendarAccount{
|
|
UserID: user.ID, Provider: "google", RefreshToken: "r",
|
|
AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour),
|
|
}); 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/calendar/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()
|
|
|
|
if took := time.Since(started); took > 10*time.Second {
|
|
t.Errorf("the sync took %s; the budget is not being applied", took.Round(time.Second))
|
|
}
|
|
if resp.StatusCode >= 500 {
|
|
t.Errorf("status %d — a slow Google is not this server breaking", resp.StatusCode)
|
|
}
|
|
}
|