Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad
the link-only fallback that needs no Zoom app. Answers
[#35](https://github.com/Calnode/calnode/issues/35).

- **Microsoft calendars can be chosen as the one bookings are written into.** Since 0.5.0
the calendar picker marked every Microsoft calendar "(read-only)" and disabled its Book
option. The calendar list read Graph's `canEdit` but left it out of `$select`, so Graph
never returned it and every calendar decoded as not writable. It is now requested, and a
test fails if that request omits any property the response decodes.

## [0.9.0] - 2026-09-10

### Added
Expand Down
6 changes: 5 additions & 1 deletion internal/calendar/microsoft/calendars.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ func (c *Client) clientForAccount(ctx context.Context, userID, accountEmail stri
return c.buildClient(ctx, userID, accessEnc, refreshEnc, calID, expiryStr, email)
}

// msCalListResp is the GET /me/calendars response. Every property decoded here must also
// be named in ListCalendars' $select: Graph returns only the selected properties, and an
// absent bool decodes as false without error.
// TestListCalendars_selectNamesEveryDecodedProperty holds the two together.
type msCalListResp struct {
Value []struct {
ID string `json:"id"`
Expand All @@ -45,7 +49,7 @@ func (c *Client) ListCalendars(ctx context.Context, userID, accountEmail string)
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
c.apiBase+"/me/calendars?$select=id,name,isDefaultCalendar&$top=100", nil)
c.apiBase+"/me/calendars?$select=id,name,isDefaultCalendar,canEdit&$top=100", nil)
if err != nil {
return nil, err
}
Expand Down
133 changes: 133 additions & 0 deletions internal/calendar/microsoft/microsoft_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -223,6 +225,137 @@ func TestCancelEvent_alreadyGoneIsOK(t *testing.T) {
}
}

// fakeGraphCalendars serves GET /me/calendars the way Graph does: with $select, each
// calendar carries only the selected properties. A fake that returned every property
// regardless could never notice a decoded property missing from the request's $select.
// onSelect, when non-nil, receives the raw $select value.
func fakeGraphCalendars(t *testing.T, cals []map[string]any, onSelect func(string)) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/me/calendars") {
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
sel := r.URL.Query().Get("$select")
if onSelect != nil {
onSelect(sel)
}
value := cals
if sel != "" {
value = make([]map[string]any, 0, len(cals))
for _, cal := range cals {
picked := map[string]any{}
for _, prop := range strings.Split(sel, ",") {
if v, ok := cal[prop]; ok {
picked[prop] = v
}
}
value = append(value, picked)
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"value": value})
}))
}

// graphCalendars are two calendars as Graph describes them: the user's own default, and
// one shared with them read-only (canEdit false).
var graphCalendars = []map[string]any{
{
"id": "cal-own", "name": "Calendar", "isDefaultCalendar": true, "canEdit": true,
"canShare": true, "color": "auto",
"owner": map[string]any{"name": "Alex", "address": "alex@example.com"},
},
{
"id": "cal-shared", "name": "Team rota", "isDefaultCalendar": false, "canEdit": false,
"canShare": false, "color": "lightBlue",
"owner": map[string]any{"name": "Sam", "address": "sam@example.com"},
},
}

func TestListCalendars_writableFollowsCanEdit(t *testing.T) {
c := newTestClient(t)
connect(t, c, "u1")

srv := fakeGraphCalendars(t, graphCalendars, nil)
defer srv.Close()
c.apiBase = srv.URL

got, err := c.ListCalendars(context.Background(), "u1", "")
if err != nil {
t.Fatalf("ListCalendars: %v", err)
}
want := []calendar.CalendarInfo{
{ID: "cal-own", Name: "Calendar", Primary: true, Writable: true},
{ID: "cal-shared", Name: "Team rota", Primary: false, Writable: false},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("ListCalendars =\n %+v\nwant\n %+v", got, want)
}
}

// decodedJSONNames lists the JSON property names a struct decodes. For a Graph response
// item that is the set of properties its request has to $select.
func decodedJSONNames(t *testing.T, typ reflect.Type) []string {
t.Helper()
if typ.Kind() != reflect.Struct {
t.Fatalf("decodedJSONNames(%s): want a struct", typ)
}
var names []string
for i := 0; i < typ.NumField(); i++ {
f := typ.Field(i)
if f.Anonymous {
// encoding/json promotes an embedded struct's fields; not needed yet, so refuse
// rather than under-report.
t.Fatalf("decodedJSONNames(%s): embedded field %s is not handled", typ, f.Name)
}
if !f.IsExported() {
continue
}
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
if name == "-" {
continue
}
if name == "" {
name = f.Name
}
names = append(names, name)
}
return names
}

// Graph omits any property the request does not $select, and an absent bool decodes as
// false without error. So every property the response item decodes must be selected, or
// it silently reads as its zero value in production while a lenient fake hides it.
func TestListCalendars_selectNamesEveryDecodedProperty(t *testing.T) {
c := newTestClient(t)
connect(t, c, "u1")

var gotSelect string
srv := fakeGraphCalendars(t, graphCalendars, func(s string) { gotSelect = s })
defer srv.Close()
c.apiBase = srv.URL

if _, err := c.ListCalendars(context.Background(), "u1", ""); err != nil {
t.Fatalf("ListCalendars: %v", err)
}
if gotSelect == "" {
return // no $select: Graph returns the default properties, which is not this bug
}
selected := map[string]bool{}
for _, prop := range strings.Split(gotSelect, ",") {
selected[prop] = true
}
value, ok := reflect.TypeOf(msCalListResp{}).FieldByName("Value")
if !ok {
t.Fatal("msCalListResp has no Value field")
}
for _, name := range decodedJSONNames(t, value.Type.Elem()) {
if !selected[name] {
t.Errorf("$select=%q omits %q, which msCalListResp decodes; Graph will not return it", gotSelect, name)
}
}
}

func mkIDToken(tid string) string {
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"tid":"` + tid + `"}`))
return "header." + payload + ".sig"
Expand Down
Loading