oficina / calendar Public

Export recurrence masters once and reject mismatched data keys at boot

Paul Campbell pushed by paul@keymail.dev 423b3b53dc76eb7eebd972f02095e1bf8f0d4c3f parent 6166596
5 files changed, +222 −1
  • internal/calendar/app.go +5 −0
  • internal/calendar/key_check.go +49 −0
  • internal/calendar/release_repairs_test.go +95 −0
  • internal/calendar/store_feed.go +1 −1
  • internal/calendar/store_feed_rows.go +72 −0

CI failed — run details

diff --git a/internal/calendar/app.go b/internal/calendar/app.go
index a04ef05..86e14bf 100644
--- a/internal/calendar/app.go
+++ b/internal/calendar/app.go
@@ -124,6 +124,11 @@ func New(cfg Config) (*App, error) {
return nil, fmt.Errorf("calendar: writer handle: %w", err)
}
+ if err := checkDataKey(d.G, sealer); err != nil {
+ d.Close()
+ return nil, err
+ }
+
// ONE bounded sender for everything this app sends — sign-in,
// reminders, booking mail. The recorder stays inside it, so tests
// still read what was sent (booking spec §6).
diff --git a/internal/calendar/key_check.go b/internal/calendar/key_check.go
new file mode 100644
index 0000000..c75e250
--- /dev/null
+++ b/internal/calendar/key_check.go
@@ -0,0 +1,49 @@
+package calendar
+
+import (
+ "encoding/base64"
+ "errors"
+ "fmt"
+
+ "amadan.net/oficina/calendar/internal/calendar/seal"
+ "gorm.io/gorm"
+)
+
+const keyCheckMeta = "sealed_key_check_v1"
+const keyCheckPurpose = "key.check.v1"
+const keyCheckValue = "oficina/calendar key check v1"
+
+func checkDataKey(g *gorm.DB, sealer *seal.Sealer) error {
+ return g.Transaction(func(tx *gorm.DB) error {
+ var row Meta
+ err := tx.Where("key = ?", keyCheckMeta).First(&row).Error
+ if err == nil {
+ sealed, err := base64.StdEncoding.DecodeString(row.Value)
+ if err != nil {
+ return errors.New("calendar: invalid data key check")
+ }
+ plain, err := sealer.OpenString(keyCheckPurpose, sealed)
+ if err != nil || plain != keyCheckValue {
+ return errors.New("calendar: data key does not match database")
+ }
+ return nil
+ }
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ var calendars []Calendar
+ if err := tx.Find(&calendars).Error; err != nil {
+ return err
+ }
+ for _, c := range calendars {
+ if _, err := sealer.OpenString(seal.PurposeCalendarName, c.NameSealed); err != nil {
+ return fmt.Errorf("calendar: data key cannot open existing calendar: %w", err)
+ }
+ }
+ sealed, err := sealer.SealString(keyCheckPurpose, keyCheckValue)
+ if err != nil {
+ return err
+ }
+ return tx.Create(&Meta{Key: keyCheckMeta, Value: base64.StdEncoding.EncodeToString(sealed)}).Error
+ })
+}
diff --git a/internal/calendar/release_repairs_test.go b/internal/calendar/release_repairs_test.go
new file mode 100644
index 0000000..d62d616
--- /dev/null
+++ b/internal/calendar/release_repairs_test.go
@@ -0,0 +1,95 @@
+package calendar
+
+import (
+ "bytes"
+ "encoding/base64"
+ "io"
+ "log/slog"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestFeedExportsOneMasterAndAllOverrides(t *testing.T) {
+ a := NewForTest(t, t.TempDir())
+ ctx := t.Context()
+ p, err := a.store.EnsurePersonSetup(ctx, "feed@example.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cal, err := a.store.DefaultCalendarID(ctx, p.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ in := EventInput{CalendarID: cal, Title: "Weekly", StartLocal: "2026-09-01T09:00", EndLocal: "2026-09-01T10:00", TZ: "UTC", RRule: "FREQ=DAILY;COUNT=30", Kind: KindMeeting, Status: StatusConfirmed}
+ master, err := a.store.CreateEvent(ctx, p.ID, in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ in.RRule = ""
+ in.StartLocal, in.EndLocal = "2026-10-05T11:00", "2026-10-05T12:00"
+ if err := a.store.UpdateOccurrence(ctx, p.ID, master.ID, "2026-09-03T09:00", in); err != nil {
+ t.Fatal(err)
+ }
+ from := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC).Unix()
+ rows, err := a.store.EventsForFeed(ctx, p.ID, cal, from, from+7*86400)
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := WriteICS("Test", rows, false)
+ if len(rows) != 2 || strings.Count(out, "RRULE:") != 1 || strings.Count(out, "BEGIN:VEVENT") != 2 || !strings.Contains(out, "RECURRENCE-ID;TZID=UTC:20260903T0900") || !strings.Contains(out, "DTSTART;TZID=UTC:20261005T1100") {
+ t.Fatalf("lost master or moved override: %s", out)
+ }
+ if _, err := a.store.EventsForFeed(ctx, p.ID+100, cal, from, from+7*86400); err == nil {
+ t.Fatal("foreign feed accepted")
+ }
+ var overrideID int64
+ for _, row := range rows {
+ if row.SeriesID != nil {
+ overrideID = row.ID
+ }
+ }
+ if err := a.store.DeleteEvent(ctx, p.ID, overrideID); err != nil {
+ t.Fatal(err)
+ }
+ rows, err = a.store.EventsForFeed(ctx, p.ID, cal, from, from+7*86400)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(WriteICS("Test", rows, false), "STATUS:CANCELLED") {
+ t.Fatal("deleted override resurrected")
+ }
+}
+
+func TestBootRejectsWrongDataKeyWithAndWithoutSentinel(t *testing.T) {
+ for _, legacy := range []bool{false, true} {
+ t.Run(map[bool]string{false: "current", true: "legacy"}[legacy], func(t *testing.T) {
+ cfg := Config{DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: TestOrigin, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), DEK: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32))}
+ a, err := New(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := a.store.EnsurePersonSetup(t.Context(), "key@example.test"); err != nil {
+ t.Fatal(err)
+ }
+ if legacy {
+ if err := a.db.G.Where("key = ?", keyCheckMeta).Delete(&Meta{}).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ a.Close()
+ wrong := cfg
+ wrong.DEK = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{9}, 32))
+ if a, err := New(wrong); err == nil {
+ a.Close()
+ t.Fatal("boot accepted wrong key")
+ }
+ a, err = New(cfg)
+ if err != nil {
+ t.Fatal("correct key failed after refusal", err)
+ }
+ a.Close()
+ })
+ }
+}
diff --git a/internal/calendar/store_feed.go b/internal/calendar/store_feed.go
index 2e39b95..0b14b84 100644
--- a/internal/calendar/store_feed.go
+++ b/internal/calendar/store_feed.go
@@ -125,7 +125,7 @@ func (s *Store) RevokeFeed(ctx context.Context, personID, feedID int64) error {
// EventsForFeed reads a calendar's events for export, opening the UID
// and attaching the feed owner's own reminder offsets.
func (s *Store) EventsForFeed(ctx context.Context, personID, calendarID, from, to int64) ([]EventView, error) {
- all, err := s.EventsInRange(ctx, personID, from, to)
+ all, err := s.feedEventRows(ctx, personID, calendarID, from, to)
if err != nil {
return nil, err
}
diff --git a/internal/calendar/store_feed_rows.go b/internal/calendar/store_feed_rows.go
new file mode 100644
index 0000000..24fe683
--- /dev/null
+++ b/internal/calendar/store_feed_rows.go
@@ -0,0 +1,72 @@
+package calendar
+
+import (
+ "context"
+ "errors"
+ "sort"
+)
+
+func (s *Store) feedEventRows(ctx context.Context, personID, calendarID, from, to int64) ([]EventView, error) {
+ if _, err := s.Calendar(ctx, personID, calendarID); err != nil {
+ return nil, err
+ }
+ rows, err := s.rangeRows(s.g.WithContext(ctx), personID, from, to)
+ if err != nil {
+ return nil, err
+ }
+ events := map[int64]EventView{}
+ masters := map[int64]bool{}
+ for _, r := range rows {
+ if r.CalendarID != calendarID {
+ continue
+ }
+ id := r.ID
+ if r.SeriesID != nil {
+ id = *r.SeriesID
+ }
+ if _, exists := events[id]; exists {
+ continue
+ }
+ v, err := s.Event(ctx, personID, id)
+ if errors.Is(err, ErrNotFound) {
+ continue
+ }
+ if err != nil {
+ return nil, err
+ }
+ if v.CalendarID != calendarID {
+ continue
+ }
+ events[id] = v
+ if v.RRule != "" {
+ masters[id] = true
+ }
+ }
+ ids := make([]int64, 0, len(masters))
+ for id := range masters {
+ ids = append(ids, id)
+ }
+ overrides, err := s.overridesFor(s.g.WithContext(ctx), personID, ids)
+ if err != nil {
+ return nil, err
+ }
+ for _, r := range overrides {
+ if r.CalendarID != calendarID {
+ continue
+ }
+ v, err := s.openEvent(r.Event, r.Role, r.CalendarName, r.CalendarHue)
+ if err != nil {
+ return nil, err
+ }
+ if r.DeletedAt != nil {
+ v.Status = StatusCancelled
+ }
+ events[v.ID] = v
+ }
+ out := make([]EventView, 0, len(events))
+ for _, v := range events {
+ out = append(out, v)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
+ return out, nil
+}