3af290152c
Radicale becomes a write-only render target, not a store. sqlite stays canonical: every poll mavcaldav reads the pending reminders out of core and publishes each one as a single-event iCal resource, withdrawing the ones that have fired or been cancelled. Losing the collection costs nothing — the next tick rebuilds it, and nothing is ever read back from it. It structurally cannot write to a calendar maven only reads. The render URL and credential are their own flags, and -render-url is refused at startup when it names the collection -url reads; the only paths it addresses carry the maven-reminder- prefix, so even aimed at the wrong collection it can only touch resources it created. Rendering is off unless -render-url is given. The calendar data model now lives in one place, internal/calendar: the Event, the iCal parse it comes from and the render it goes to, the fact key/value encoding, and the source constants that say which calendars may be written to. It was a parse inlined in cmd/mavcaldav and a Sprintf in two files; #126 and #128 both need to agree with it. Fixes a latent day-boundary bug moved out of that inline parse: it took the day number off a local clock reading but built the window boundaries in UTC, so on a box east of Greenwich part of the evening fell outside "today" and the poller saw an empty calendar after 20:00 UTC. Today is now the owner's day in the owner's location, which is what the busy gate and the day plan mean.
70 lines
2.5 KiB
Go
70 lines
2.5 KiB
Go
package calendar
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestRenderICalRoundTrips(t *testing.T) {
|
|
e := ReminderEvent(7, time.Date(2026, 8, 1, 18, 30, 0, 0, time.UTC), "позвонить маме", 0)
|
|
if e.UID != "maven-reminder-7" {
|
|
t.Errorf("UID = %q", e.UID)
|
|
}
|
|
if got := e.End.Sub(e.Start); got != DefaultReminderDuration {
|
|
t.Errorf("duration = %v, want %v", got, DefaultReminderDuration)
|
|
}
|
|
if got, want := ReminderPath(7), "maven-reminder-7.ics"; got != want {
|
|
t.Errorf("ReminderPath = %q, want %q", got, want)
|
|
}
|
|
|
|
body := RenderICal([]Event{e})
|
|
if !strings.HasPrefix(body, "BEGIN:VCALENDAR\r\n") || !strings.HasSuffix(body, "END:VCALENDAR\r\n") {
|
|
t.Fatalf("not a VCALENDAR body:\n%s", body)
|
|
}
|
|
|
|
back := ParseICal([]byte(body), e.Start.Add(-time.Hour), e.Start.Add(time.Hour))
|
|
if len(back) != 1 {
|
|
t.Fatalf("got %d events back, want 1:\n%s", len(back), body)
|
|
}
|
|
if back[0].UID != e.UID || back[0].Summary != e.Summary {
|
|
t.Errorf("round trip lost identity: %+v", back[0])
|
|
}
|
|
if !back[0].Start.Equal(e.Start) || !back[0].End.Equal(e.End) {
|
|
t.Errorf("round trip lost times: %+v", back[0])
|
|
}
|
|
}
|
|
|
|
func TestRenderICalIsDeterministic(t *testing.T) {
|
|
e := ReminderEvent(1, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), "выпить воды", 0)
|
|
if RenderICal([]Event{e}) != RenderICal([]Event{e}) {
|
|
t.Error("the same reminder must render byte-identically, or every poll re-PUTs it")
|
|
}
|
|
}
|
|
|
|
// A reminder payload is owner-supplied text. It must not be able to close the
|
|
// VEVENT and inject properties of its own.
|
|
func TestRenderICalEscapesInjection(t *testing.T) {
|
|
e := ReminderEvent(2, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC),
|
|
"обед\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nSUMMARY:injected", 0)
|
|
body := RenderICal([]Event{e})
|
|
// Count line-initial occurrences: the escaped text still contains the
|
|
// characters "BEGIN:VEVENT", it just can no longer start a line.
|
|
if n := strings.Count(body, "\r\nBEGIN:VEVENT\r\n"); n != 1 {
|
|
t.Fatalf("payload injected a second VEVENT (%d):\n%s", n, body)
|
|
}
|
|
if n := strings.Count(body, "\r\nEND:VEVENT\r\n"); n != 1 {
|
|
t.Fatalf("payload closed the VEVENT early (%d):\n%s", n, body)
|
|
}
|
|
if !strings.Contains(body, `SUMMARY:обед\nEND:VEVENT`) {
|
|
t.Errorf("newlines should be escaped, not dropped:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestReminderEventEmptyPayload(t *testing.T) {
|
|
e := ReminderEvent(3, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), " ", 0)
|
|
if e.Summary != "напоминание" {
|
|
t.Errorf("Summary = %q, want the neutral RU fallback", e.Summary)
|
|
}
|
|
}
|