Export recurrence masters once and reject mismatched data keys at boot
5 files changed,
+222
−1
internal/calendar/app.go+5 −0internal/calendar/key_check.go+49 −0internal/calendar/release_repairs_test.go+95 −0internal/calendar/store_feed.go+1 −1internal/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 |
| +} |