CI / ci (push) Successful in 4m51s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
750 lines
24 KiB
Go
750 lines
24 KiB
Go
// Package gcaltest provides a fake Google OAuth server and Calendar API for
|
|
// tests of the Google Calendar connection: a token endpoint that checks the
|
|
// client, the redirect URI and PKCE, issues a distinct refresh token per
|
|
// exchange and trades a live refresh token for an access token; a revoke
|
|
// endpoint that, like Google's, revokes the whole grant: every token issued
|
|
// so far; a calendarList endpoint that requires a live grant's access
|
|
// token; and events insert and update endpoints that, like Google's, keep
|
|
// each calendar's events by id, answer an insert of an id the calendar
|
|
// already has (even a cancelled event's) with HTTP 409, and refuse a
|
|
// calendar not listed (404) or not writable (403 requiredAccessLevel). It
|
|
// never touches the network beyond its own httptest server.
|
|
package gcaltest
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"golang.org/x/oauth2"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
|
)
|
|
|
|
// Fixed credentials and tokens the fake issues and expects. The first
|
|
// exchange issues RefreshToken; exchange n after it issues RefreshToken
|
|
// followed by "-n", so every issued refresh token contains RefreshToken. Every
|
|
// exchange and refresh issues AccessToken.
|
|
const (
|
|
ClientID = "mealquest-test.apps.googleusercontent.com"
|
|
ClientSecret = "google-client-s3cret-4f1c"
|
|
RefreshToken = "1//google-refresh-t0ken-9a8b7c"
|
|
AccessToken = "ya29.google-access-t0ken-5d6e"
|
|
)
|
|
|
|
// Google is the fake. Its exported fields script the next responses; set
|
|
// them before the request they affect.
|
|
type Google struct {
|
|
Server *httptest.Server
|
|
// RedirectURL is the redirect URI the token endpoint requires.
|
|
RedirectURL string
|
|
|
|
mu sync.Mutex
|
|
// omitRefresh leaves refresh_token out of the token response.
|
|
omitRefresh bool
|
|
// scope is the token response's granted scopes.
|
|
scope string
|
|
// tokenStatus, when not 200, fails the token endpoint with a body that
|
|
// quotes the refresh token and client secret.
|
|
tokenStatus int
|
|
// revokeStatus and revokeBody answer the revoke endpoint; revokeBody
|
|
// quotes the token when revokeStatus is not 200 or 400.
|
|
revokeStatus int
|
|
revokeBody string
|
|
codes map[string]string // code -> PKCE challenge
|
|
tokenCalls int
|
|
revoked []string
|
|
// issued is every refresh token issued, in order; dead is those a
|
|
// revoke has invalidated.
|
|
issued []string
|
|
dead map[string]bool
|
|
// tokenHold, revokeHold and calendarHold, when set, stop the next
|
|
// request to that endpoint.
|
|
tokenHold, revokeHold, calendarHold *hold
|
|
// calendars is what the calendarList endpoint lists, pageSize entries
|
|
// to a page (all of them when 0).
|
|
calendars []Calendar
|
|
pageSize int
|
|
// calendarStatus, when not 200, fails the calendarList endpoint with a
|
|
// body that quotes the access token: every request when calendarFails
|
|
// is 0, else the next calendarFails requests.
|
|
calendarStatus int
|
|
calendarFails int
|
|
calendarCalls int
|
|
// rejectAccess makes the Calendar API answer HTTP 401 to every access
|
|
// token, while the grant stays live at the token endpoint.
|
|
rejectAccess bool
|
|
// events holds each calendar's events by id.
|
|
events map[string]map[string]Event
|
|
// inserts and updates count the events created and updated.
|
|
inserts, updates int
|
|
// eventWrites counts the requests that reached the events endpoints;
|
|
// failWrite, when not 0, fails the requests that make it that count up
|
|
// to failLast with failStatus and a body that quotes the access token.
|
|
eventWrites, failWrite, failLast, failStatus int
|
|
// eventHold, when set, stops the next events request.
|
|
eventHold *hold
|
|
}
|
|
|
|
// Event is an event as the fake keeps it: what MealQuest sent.
|
|
type Event struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
Summary string `json:"summary"`
|
|
Description string `json:"description"`
|
|
Start EventTime `json:"start"`
|
|
End EventTime `json:"end"`
|
|
}
|
|
|
|
// EventTime is an event's start or end.
|
|
type EventTime struct {
|
|
DateTime string `json:"dateTime"`
|
|
TimeZone string `json:"timeZone"`
|
|
}
|
|
|
|
// Calendar is one calendarList entry. The fake answers each with Google's
|
|
// other fields too, so tests can check that nothing beyond id, summary,
|
|
// primary and accessRole reaches the household.
|
|
type Calendar struct {
|
|
ID string
|
|
Summary string
|
|
Primary bool
|
|
AccessRole string
|
|
Description string
|
|
SummaryOverride string
|
|
}
|
|
|
|
// hold stops one request: arrived is closed when it arrives, and it goes on
|
|
// once release is closed.
|
|
type hold struct {
|
|
arrived, release chan struct{}
|
|
}
|
|
|
|
// New starts a fake Google that requires redirectURL at its token endpoint.
|
|
// It grants every scope in gcal.Scopes with a refresh token by default.
|
|
func New(t *testing.T, redirectURL string) *Google {
|
|
t.Helper()
|
|
g := &Google{
|
|
RedirectURL: redirectURL,
|
|
scope: strings.Join(gcal.Scopes, " "),
|
|
tokenStatus: http.StatusOK,
|
|
revokeStatus: http.StatusOK,
|
|
calendarStatus: http.StatusOK,
|
|
codes: map[string]string{},
|
|
dead: map[string]bool{},
|
|
events: map[string]map[string]Event{},
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("POST /token", g.token)
|
|
mux.HandleFunc("POST /revoke", g.revoke)
|
|
mux.HandleFunc("GET /calendar/v3/users/me/calendarList", g.calendarList)
|
|
mux.HandleFunc("POST /calendar/v3/calendars/{calendar}/events", g.insertEvent)
|
|
mux.HandleFunc("PUT /calendar/v3/calendars/{calendar}/events/{event}", g.updateEvent)
|
|
g.Server = httptest.NewServer(mux)
|
|
t.Cleanup(g.Server.Close)
|
|
return g
|
|
}
|
|
|
|
// Endpoint is the fake's authorization and token endpoint, with Google's
|
|
// client authentication style.
|
|
func (g *Google) Endpoint() oauth2.Endpoint {
|
|
return oauth2.Endpoint{
|
|
AuthURL: g.Server.URL + "/auth",
|
|
TokenURL: g.Server.URL + "/token",
|
|
AuthStyle: gcal.GoogleEndpoint.AuthStyle,
|
|
}
|
|
}
|
|
|
|
// RevokeURL is the fake's revocation endpoint.
|
|
func (g *Google) RevokeURL() string { return g.Server.URL + "/revoke" }
|
|
|
|
// CalendarAPI is the fake's Calendar API base URL.
|
|
func (g *Google) CalendarAPI() string { return g.Server.URL + "/calendar/v3" }
|
|
|
|
// Authorize plays the consent screen for an authorization request with PKCE
|
|
// challenge and returns the code Google would send back.
|
|
func (g *Google) Authorize(challenge string) string {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
code := fmt.Sprintf("code-%d", len(g.codes)+1)
|
|
g.codes[code] = challenge
|
|
return code
|
|
}
|
|
|
|
// Connect connects svc to the fake as a household completing the consent
|
|
// screen does. svc must use the fake's endpoints and redirect URL.
|
|
func (g *Google) Connect(t *testing.T, svc *gcal.Service) {
|
|
t.Helper()
|
|
session := auth.Session{Subject: "gcaltest", CSRFToken: "gcaltest-session"}
|
|
started := httptest.NewRecorder()
|
|
raw, err := svc.Connect(started, session)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
consent, err := url.Parse(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
q := url.Values{
|
|
"state": {consent.Query().Get("state")},
|
|
"code": {g.Authorize(consent.Query().Get("code_challenge"))},
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, gcal.CallbackPath+"?"+q.Encode(), nil)
|
|
for _, c := range started.Result().Cookies() {
|
|
req.AddCookie(c)
|
|
}
|
|
if err := svc.Complete(httptest.NewRecorder(), req, session); err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
}
|
|
|
|
// OmitRefreshToken makes the token endpoint answer without a refresh token,
|
|
// as Google does when it reuses an earlier grant.
|
|
func (g *Google) OmitRefreshToken() {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.omitRefresh = true
|
|
}
|
|
|
|
// GrantScope sets the scopes the token endpoint reports as granted.
|
|
func (g *Google) GrantScope(scope string) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.scope = scope
|
|
}
|
|
|
|
// FailToken makes the token endpoint answer status with a body that quotes
|
|
// the refresh token and client secret.
|
|
func (g *Google) FailToken(status int) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.tokenStatus = status
|
|
}
|
|
|
|
// AnswerRevoke makes the revoke endpoint answer status with body. A body of
|
|
// "" with a status other than 200 quotes the token it was sent.
|
|
func (g *Google) AnswerRevoke(status int, body string) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.revokeStatus, g.revokeBody = status, body
|
|
}
|
|
|
|
// RevokeGrant revokes every token issued so far, as the household removing
|
|
// MealQuest's access in their Google account does.
|
|
func (g *Google) RevokeGrant() {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
for _, t := range g.issued {
|
|
g.dead[t] = true
|
|
}
|
|
}
|
|
|
|
// SetCalendars makes the calendarList endpoint list cals, pageSize to a
|
|
// page (all on one page when 0).
|
|
func (g *Google) SetCalendars(pageSize int, cals ...Calendar) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.calendars, g.pageSize = slices.Clone(cals), pageSize
|
|
}
|
|
|
|
// FailCalendarList makes the calendarList endpoint answer status with a
|
|
// body that quotes the access token.
|
|
func (g *Google) FailCalendarList(status int) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.calendarStatus, g.calendarFails = status, 0
|
|
}
|
|
|
|
// FailCalendarListNext makes the next n calendarList requests answer status
|
|
// as FailCalendarList does, and later ones answer normally.
|
|
func (g *Google) FailCalendarListNext(n, status int) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.calendarStatus, g.calendarFails = status, n
|
|
}
|
|
|
|
// RejectAccessToken makes the Calendar API answer HTTP 401 to every access
|
|
// token while refreshing the grant still succeeds, as Google does when it
|
|
// refuses the access token it just issued.
|
|
func (g *Google) RejectAccessToken() {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.rejectAccess = true
|
|
}
|
|
|
|
// AcceptAccessToken undoes RejectAccessToken, as Google does for the grant
|
|
// of a later connection.
|
|
func (g *Google) AcceptAccessToken() {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.rejectAccess = false
|
|
}
|
|
|
|
// CalendarCalls is how many requests reached the calendarList endpoint.
|
|
func (g *Google) CalendarCalls() int {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return g.calendarCalls
|
|
}
|
|
|
|
// HoldCalendarList stops the next calendarList request before it is
|
|
// answered. It returns a channel closed when the request arrives and the
|
|
// function that lets it go on.
|
|
func (g *Google) HoldCalendarList() (arrived <-chan struct{}, release func()) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.calendarHold = newHold()
|
|
return g.calendarHold.arrived, g.calendarHold.releaseFunc()
|
|
}
|
|
|
|
// Events returns the events of calendar, by id.
|
|
func (g *Google) Events(calendar string) map[string]Event {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
out := map[string]Event{}
|
|
for id, e := range g.events[calendar] {
|
|
out[id] = e
|
|
}
|
|
return out
|
|
}
|
|
|
|
// EventWrites returns how many events were inserted and how many updated.
|
|
func (g *Google) EventWrites() (inserts, updates int) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return g.inserts, g.updates
|
|
}
|
|
|
|
// ResetEventWrites zeroes the insert and update counts.
|
|
func (g *Google) ResetEventWrites() {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.inserts, g.updates = 0, 0
|
|
}
|
|
|
|
// CancelEvent marks event id of calendar cancelled, as deleting it in
|
|
// Google Calendar does: Google keeps the id.
|
|
func (g *Google) CancelEvent(calendar, id string) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
e := g.events[calendar][id]
|
|
e.Status = "cancelled"
|
|
g.events[calendar][id] = e
|
|
}
|
|
|
|
// FailEventWrite makes the nth request to the events endpoints from now
|
|
// (1 for the next) answer status with a body that quotes the access token.
|
|
func (g *Google) FailEventWrite(n, status int) {
|
|
g.FailEventWrites(n, 1, status)
|
|
}
|
|
|
|
// FailEventWrites makes count requests to the events endpoints in a row,
|
|
// from the nth from now (1 for the next), answer status with a body that
|
|
// quotes the access token.
|
|
func (g *Google) FailEventWrites(n, count, status int) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.failWrite, g.failLast, g.failStatus = g.eventWrites+n, g.eventWrites+n+count-1, status
|
|
}
|
|
|
|
// EventRequests is how many requests reached the events endpoints.
|
|
func (g *Google) EventRequests() int {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return g.eventWrites
|
|
}
|
|
|
|
// HoldEvents stops the next request to the events endpoints before it is
|
|
// answered. It returns a channel closed when the request arrives and the
|
|
// function that lets it go on.
|
|
func (g *Google) HoldEvents() (arrived <-chan struct{}, release func()) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.eventHold = newHold()
|
|
return g.eventHold.arrived, g.eventHold.releaseFunc()
|
|
}
|
|
|
|
// TokenCalls is how many requests, exchanges and refreshes, reached the
|
|
// token endpoint.
|
|
func (g *Google) TokenCalls() int {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return g.tokenCalls
|
|
}
|
|
|
|
// Revoked lists the tokens the revoke endpoint received, in order.
|
|
func (g *Google) Revoked() []string {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return append([]string(nil), g.revoked...)
|
|
}
|
|
|
|
// Live reports whether token was issued by the fake and no revoke has
|
|
// invalidated it since.
|
|
func (g *Google) Live(token string) bool {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return slices.Contains(g.issued, token) && !g.dead[token]
|
|
}
|
|
|
|
// HoldToken stops the next token request after its refresh token is issued
|
|
// and before it is answered, so a revoke can land in between. It returns a
|
|
// channel closed when the request arrives and the function that lets it go
|
|
// on.
|
|
func (g *Google) HoldToken() (arrived <-chan struct{}, release func()) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.tokenHold = newHold()
|
|
return g.tokenHold.arrived, g.tokenHold.releaseFunc()
|
|
}
|
|
|
|
// HoldRevoke stops the next revoke request before Google acts on it. It
|
|
// returns a channel closed when the request arrives and the function that
|
|
// lets it go on.
|
|
func (g *Google) HoldRevoke() (arrived <-chan struct{}, release func()) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.revokeHold = newHold()
|
|
return g.revokeHold.arrived, g.revokeHold.releaseFunc()
|
|
}
|
|
|
|
func newHold() *hold {
|
|
return &hold{arrived: make(chan struct{}), release: make(chan struct{})}
|
|
}
|
|
|
|
func (h *hold) releaseFunc() func() {
|
|
var once sync.Once
|
|
return func() { once.Do(func() { close(h.release) }) }
|
|
}
|
|
|
|
// wait stops the request at h, if any, without holding g.mu.
|
|
func (g *Google) wait(h *hold) {
|
|
if h == nil {
|
|
return
|
|
}
|
|
g.mu.Unlock()
|
|
close(h.arrived)
|
|
<-h.release
|
|
g.mu.Lock()
|
|
}
|
|
|
|
func (g *Google) token(w http.ResponseWriter, r *http.Request) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.tokenCalls++
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
f := r.PostForm
|
|
if f.Get("grant_type") == "refresh_token" {
|
|
g.refresh(w, f)
|
|
return
|
|
}
|
|
challenge, known := g.codes[f.Get("code")]
|
|
delete(g.codes, f.Get("code"))
|
|
switch {
|
|
case f.Get("grant_type") != "authorization_code":
|
|
oauthError(w, http.StatusBadRequest, "unsupported_grant_type")
|
|
return
|
|
case f.Get("client_id") != ClientID || f.Get("client_secret") != ClientSecret:
|
|
oauthError(w, http.StatusUnauthorized, "invalid_client")
|
|
return
|
|
case !known, f.Get("redirect_uri") != g.RedirectURL, s256(f.Get("code_verifier")) != challenge:
|
|
oauthError(w, http.StatusBadRequest, "invalid_grant")
|
|
return
|
|
}
|
|
if g.tokenStatus != http.StatusOK {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(g.tokenStatus)
|
|
fmt.Fprintf(w, `{"error":"invalid_grant","error_description":"echo %s %s"}`, RefreshToken, ClientSecret)
|
|
return
|
|
}
|
|
refresh := ""
|
|
if !g.omitRefresh {
|
|
issue := RefreshToken
|
|
if n := len(g.issued); n > 0 {
|
|
issue = fmt.Sprintf("%s-%d", RefreshToken, n+1)
|
|
}
|
|
g.issued = append(g.issued, issue)
|
|
refresh = fmt.Sprintf(`"refresh_token":%q,`, issue)
|
|
}
|
|
h := g.tokenHold
|
|
g.tokenHold = nil
|
|
g.wait(h)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"access_token":%q,%s"expires_in":3599,"scope":%q,"token_type":"Bearer"}`,
|
|
AccessToken, refresh, g.scope)
|
|
}
|
|
|
|
// refresh answers a refresh_token grant: an access token for a live refresh
|
|
// token, invalid_grant quoting the token for any other.
|
|
func (g *Google) refresh(w http.ResponseWriter, f url.Values) {
|
|
token := f.Get("refresh_token")
|
|
switch {
|
|
case f.Get("client_id") != ClientID || f.Get("client_secret") != ClientSecret:
|
|
oauthError(w, http.StatusUnauthorized, "invalid_client")
|
|
return
|
|
case !slices.Contains(g.issued, token) || g.dead[token]:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
fmt.Fprintf(w, `{"error":"invalid_grant","error_description":"Token has been expired or revoked: %s"}`, token)
|
|
return
|
|
case g.tokenStatus != http.StatusOK:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(g.tokenStatus)
|
|
fmt.Fprintf(w, `{"error":"server_error","error_description":"echo %s %s"}`, token, ClientSecret)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"access_token":%q,"expires_in":3599,"scope":%q,"token_type":"Bearer"}`, AccessToken, g.scope)
|
|
}
|
|
|
|
// calendarList answers like Google's calendarList.list: the scripted
|
|
// calendars, a page at a time, to a live grant's access token.
|
|
func (g *Google) calendarList(w http.ResponseWriter, r *http.Request) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.calendarCalls++
|
|
h := g.calendarHold
|
|
g.calendarHold = nil
|
|
g.wait(h)
|
|
auth := r.Header.Get("Authorization")
|
|
if auth != "Bearer "+AccessToken || !g.grantLive() || g.rejectAccess {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
fmt.Fprintf(w, `{"error":{"code":401,"message":"Invalid Credentials: %s"}}`, auth)
|
|
return
|
|
}
|
|
if status := g.calendarStatus; status != http.StatusOK {
|
|
if g.calendarFails > 0 {
|
|
g.calendarFails--
|
|
if g.calendarFails == 0 {
|
|
g.calendarStatus = http.StatusOK
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
fmt.Fprintf(w, `{"error":{"code":%d,"message":"echo %s"}}`, status, auth)
|
|
return
|
|
}
|
|
start := 0
|
|
if pt := r.URL.Query().Get("pageToken"); pt != "" {
|
|
n, err := strconv.Atoi(strings.TrimPrefix(pt, "page-"))
|
|
if err != nil || n <= 0 || n > len(g.calendars) {
|
|
http.Error(w, "bad pageToken", http.StatusBadRequest)
|
|
return
|
|
}
|
|
start = n
|
|
}
|
|
end := len(g.calendars)
|
|
if g.pageSize > 0 {
|
|
end = min(start+g.pageSize, end)
|
|
}
|
|
items := []map[string]any{}
|
|
for _, c := range g.calendars[start:end] {
|
|
item := map[string]any{
|
|
"kind": "calendar#calendarListEntry",
|
|
"etag": `"1700000000000000"`,
|
|
"id": c.ID,
|
|
"summary": c.Summary,
|
|
"description": c.Description,
|
|
"timeZone": "America/New_York",
|
|
"colorId": "14",
|
|
"backgroundColor": "#9fe1e7",
|
|
"foregroundColor": "#000000",
|
|
"selected": true,
|
|
"accessRole": c.AccessRole,
|
|
"defaultReminders": []map[string]any{
|
|
{"method": "popup", "minutes": 30},
|
|
},
|
|
"conferenceProperties": map[string]any{"allowedConferenceSolutionTypes": []string{"hangoutsMeet"}},
|
|
}
|
|
if c.Primary {
|
|
item["primary"] = true
|
|
}
|
|
if c.SummaryOverride != "" {
|
|
item["summaryOverride"] = c.SummaryOverride
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
page := map[string]any{"kind": "calendar#calendarList", "etag": `"p33k"`, "items": items}
|
|
if end < len(g.calendars) {
|
|
page["nextPageToken"] = fmt.Sprintf("page-%d", end)
|
|
} else {
|
|
page["nextSyncToken"] = "sync-token"
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(page); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
// insertEvent answers like Google's events.insert with a client-chosen id.
|
|
func (g *Google) insertEvent(w http.ResponseWriter, r *http.Request) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
cal, ok := g.eventCalendar(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var e Event
|
|
if err := json.NewDecoder(r.Body).Decode(&e); err != nil || e.ID == "" {
|
|
http.Error(w, "bad event", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if _, exists := g.events[cal][e.ID]; exists {
|
|
apiError(w, http.StatusConflict, "duplicate", "The requested identifier already exists.")
|
|
return
|
|
}
|
|
if g.events[cal] == nil {
|
|
g.events[cal] = map[string]Event{}
|
|
}
|
|
g.events[cal][e.ID] = e
|
|
g.inserts++
|
|
writeEvent(w, e)
|
|
}
|
|
|
|
// updateEvent answers like Google's events.update.
|
|
func (g *Google) updateEvent(w http.ResponseWriter, r *http.Request) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
cal, ok := g.eventCalendar(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id := r.PathValue("event")
|
|
if _, exists := g.events[cal][id]; !exists {
|
|
apiError(w, http.StatusNotFound, "notFound", "Not Found")
|
|
return
|
|
}
|
|
var e Event
|
|
if err := json.NewDecoder(r.Body).Decode(&e); err != nil || e.ID != id {
|
|
http.Error(w, "bad event", http.StatusBadRequest)
|
|
return
|
|
}
|
|
g.events[cal][id] = e
|
|
g.updates++
|
|
writeEvent(w, e)
|
|
}
|
|
|
|
// eventCalendar runs the checks every events request shares: the hold, a
|
|
// live grant's access token, a scripted failure, and a listed, writable
|
|
// calendar, which it returns. When a check fails it answers and returns
|
|
// false. The caller holds g.mu.
|
|
func (g *Google) eventCalendar(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
g.eventWrites++
|
|
h := g.eventHold
|
|
g.eventHold = nil
|
|
g.wait(h)
|
|
auth := r.Header.Get("Authorization")
|
|
if auth != "Bearer "+AccessToken || !g.grantLive() || g.rejectAccess {
|
|
apiError(w, http.StatusUnauthorized, "authError", "Invalid Credentials: "+auth)
|
|
return "", false
|
|
}
|
|
if g.failWrite != 0 && g.eventWrites >= g.failWrite && g.eventWrites <= g.failLast {
|
|
apiError(w, g.failStatus, "backendError", "echo "+auth)
|
|
return "", false
|
|
}
|
|
cal := r.PathValue("calendar")
|
|
i := slices.IndexFunc(g.calendars, func(c Calendar) bool { return c.ID == cal })
|
|
switch {
|
|
case i < 0:
|
|
apiError(w, http.StatusNotFound, "notFound", "Not Found")
|
|
return "", false
|
|
case g.calendars[i].AccessRole != "writer" && g.calendars[i].AccessRole != "owner":
|
|
apiError(w, http.StatusForbidden, "requiredAccessLevel", "You need to have writer access to this calendar.")
|
|
return "", false
|
|
}
|
|
return cal, true
|
|
}
|
|
|
|
// writeEvent answers with e as Google does, with fields of its own.
|
|
func writeEvent(w http.ResponseWriter, e Event) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(map[string]any{
|
|
"kind": "calendar#event", "etag": `"3300000000000000"`, "id": e.ID, "status": e.Status,
|
|
"htmlLink": "https://www.google.com/calendar/event?eid=" + e.ID,
|
|
"summary": e.Summary, "description": e.Description, "start": e.Start, "end": e.End,
|
|
"iCalUID": e.ID + "@google.com", "sequence": 0,
|
|
}); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
// apiError answers a Calendar API error with reason and message.
|
|
func apiError(w http.ResponseWriter, status int, reason, message string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if err := json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
|
|
"code": status, "message": message,
|
|
"errors": []map[string]any{{"domain": "global", "reason": reason, "message": message}},
|
|
}}); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
// grantLive reports whether any refresh token issued is still live. The
|
|
// caller holds g.mu.
|
|
func (g *Google) grantLive() bool {
|
|
for _, t := range g.issued {
|
|
if !g.dead[t] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (g *Google) revoke(w http.ResponseWriter, r *http.Request) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
|
|
http.Error(w, "want a form", http.StatusUnsupportedMediaType)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
h := g.revokeHold
|
|
g.revokeHold = nil
|
|
g.wait(h)
|
|
token := r.PostForm.Get("token")
|
|
g.revoked = append(g.revoked, token)
|
|
if g.revokeStatus == http.StatusOK && slices.Contains(g.issued, token) {
|
|
// Google revokes the grant, not just the token: every token issued
|
|
// to it so far stops working.
|
|
for _, t := range g.issued {
|
|
g.dead[t] = true
|
|
}
|
|
}
|
|
body := g.revokeBody
|
|
if body == "" && g.revokeStatus != http.StatusOK {
|
|
body = "cannot revoke " + token
|
|
}
|
|
w.WriteHeader(g.revokeStatus)
|
|
fmt.Fprint(w, body)
|
|
}
|
|
|
|
func oauthError(w http.ResponseWriter, status int, code string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
fmt.Fprintf(w, `{"error":%q}`, code)
|
|
}
|
|
|
|
// s256 is the PKCE S256 challenge for verifier.
|
|
func s256(verifier string) string {
|
|
sum := sha256.Sum256([]byte(verifier))
|
|
return base64.RawURLEncoding.EncodeToString(sum[:])
|
|
}
|