Files
mailgoserver/internal/webui/webmail_calendar_test.go
T
2026-08-20 19:42:20 +01:00

388 lines
16 KiB
Go

package webui
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
)
// TestWebmailCalendarEventsInRangeExpandsRecurrence confirms the JSON events endpoint
// expands a weekly-recurring event into one occurrence per matching week within the
// queried range, alongside a plain non-recurring event.
func TestWebmailCalendarEventsInRangeExpandsRecurrence(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "calgrid@example.com", domains[0].ID, "calgrid-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
weeklyStart := time.Date(2026, 3, 3, 9, 0, 0, 0, time.UTC) // a Tuesday
if _, err := app.DB.CreateEvent(mailboxID, "Weekly Sync", "", "", weeklyStart, weeklyStart.Add(time.Hour), false, "FREQ=WEEKLY", ""); err != nil {
t.Fatal(err)
}
singleStart := time.Date(2026, 3, 12, 14, 0, 0, 0, time.UTC)
if _, err := app.DB.CreateEvent(mailboxID, "One-off Review", "", "", singleStart, singleStart.Add(time.Hour), false, "", ""); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/calendar/events?start=2026-03-01&end=2026-04-01", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var events []calendarEventJSON
if err := json.Unmarshal(rec.Body.Bytes(), &events); err != nil {
t.Fatal(err)
}
weeklyCount, sawSingle := 0, false
for _, e := range events {
switch e.Summary {
case "Weekly Sync":
weeklyCount++
if !e.Recurring {
t.Error("expected the weekly event's occurrences flagged Recurring=true")
}
case "One-off Review":
sawSingle = true
if e.Recurring {
t.Error("expected the non-recurring event flagged Recurring=false")
}
}
}
if weeklyCount != 5 { // Tuesdays in March 2026: 3, 10, 17, 24, 31
t.Errorf("expected 5 weekly occurrences, got %d", weeklyCount)
}
if !sawSingle {
t.Error("expected the one-off event present")
}
}
// TestWebmailCalendarEventSaveCreateUpdateDelete exercises the full self-service event
// CRUD flow through the add/edit modal's shared save endpoint — mirrors
// TestWebmailContactCreateEditDelete.
func TestWebmailCalendarEventSaveCreateUpdateDelete(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "calsave@example.com", domains[0].ID, "calsave-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
save := func(id, summary, start, end, allDay, recurrence, reminder string) *httptest.ResponseRecorder {
form := url.Values{
"id": {id}, "summary": {summary}, "start": {start}, "end": {end},
"all_day": {allDay}, "recurrence": {recurrence}, "reminder": {reminder},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/calendar/events/save", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
if rec := save("", "Planning", "2026-03-10T14:00", "2026-03-10T15:00", "0", "weekly", "15"); rec.Code != http.StatusFound {
t.Fatalf("create: status=%d body=%s", rec.Code, rec.Body.String())
}
events, err := app.DB.ListAllEvents(mailboxID)
if err != nil || len(events) != 1 || events[0].Summary != "Planning" || events[0].RRule != "FREQ=WEEKLY" {
t.Fatalf("expected 1 event named Planning, weekly, got %+v (err=%v)", events, err)
}
if events[0].ReminderMinutes == nil || *events[0].ReminderMinutes != 15 {
t.Fatalf("expected reminder 15, got %+v", events[0].ReminderMinutes)
}
id := events[0].ID
// Edit: change title, clear recurrence and reminder.
if rec := save(strconv.FormatInt(id, 10), "Planning (final)", "2026-03-10T14:00", "2026-03-10T15:00", "0", "none", ""); rec.Code != http.StatusFound {
t.Fatalf("edit: status=%d body=%s", rec.Code, rec.Body.String())
}
updated, err := app.DB.GetEventByID(mailboxID, id)
if err != nil || updated == nil || updated.Summary != "Planning (final)" || updated.RRule != "" || updated.ReminderMinutes != nil {
t.Fatalf("expected updated event with recurrence/reminder cleared, got %+v (err=%v)", updated, err)
}
// Rejects an end time not after start.
if rec := save("", "Bad", "2026-03-10T14:00", "2026-03-10T14:00", "0", "none", ""); rec.Code != http.StatusFound {
t.Fatalf("status=%d", rec.Code)
}
stillOne, err := app.DB.ListAllEvents(mailboxID)
if err != nil || len(stillOne) != 1 {
t.Fatalf("expected the invalid event rejected (still 1), got %d (err=%v)", len(stillOne), err)
}
delReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/calendar/events/"+strconv.FormatInt(id, 10)+"/delete", nil)
delReq.AddCookie(cookie)
delRec := httptest.NewRecorder()
mux.ServeHTTP(delRec, delReq)
if delRec.Code != http.StatusFound {
t.Fatalf("delete: status=%d body=%s", delRec.Code, delRec.Body.String())
}
remaining, err := app.DB.ListAllEvents(mailboxID)
if err != nil || len(remaining) != 0 {
t.Fatalf("expected no events left, got %+v (err=%v)", remaining, err)
}
}
// TestWebmailCalendarEventSaveAllDaySameDay confirms a single-day all-day event (start
// and end submitted as the same date, the normal case) saves successfully — a real bug
// found live: the end-after-start validation used to run before the all-day end-date
// bump, rejecting every single-day all-day event as a zero-length range.
func TestWebmailCalendarEventSaveAllDaySameDay(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "calallday@example.com", domains[0].ID, "calallday-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
form := url.Values{
"id": {""}, "summary": {"Conference Day"}, "start": {"2026-03-15"}, "end": {"2026-03-15"},
"all_day": {"1"}, "recurrence": {"none"}, "reminder": {""},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/calendar/events/save", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
events, err := app.DB.ListAllEvents(mailboxID)
if err != nil || len(events) != 1 || events[0].Summary != "Conference Day" || !events[0].AllDay {
t.Fatalf("expected 1 all-day event saved, got %+v (err=%v)", events, err)
}
if !events[0].EndAt.After(events[0].StartAt) {
t.Fatalf("expected EndAt bumped past StartAt for an all-day event, got start=%v end=%v", events[0].StartAt, events[0].EndAt)
}
// And it shows up on the grid for its one day.
getReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/calendar/events?start=2026-03-01&end=2026-04-01", nil)
getReq.AddCookie(cookie)
getRec := httptest.NewRecorder()
mux.ServeHTTP(getRec, getReq)
var occurrences []calendarEventJSON
if err := json.Unmarshal(getRec.Body.Bytes(), &occurrences); err != nil {
t.Fatal(err)
}
found := false
for _, o := range occurrences {
if o.Summary == "Conference Day" {
found = true
}
}
if !found {
t.Fatal("expected the all-day event present in the events-in-range JSON")
}
}
// TestWebmailCalendarEventGetPrefillsEditForm confirms the edit-modal prefill endpoint
// returns the raw (non-expanded) stored fields, not occurrence data.
func TestWebmailCalendarEventGetPrefillsEditForm(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "calprefill@example.com", domains[0].ID, "calprefill-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
start := time.Date(2026, 3, 10, 14, 0, 0, 0, time.UTC)
id, err := app.DB.CreateEvent(mailboxID, "Retro", "notes here", "Room 3", start, start.Add(time.Hour), false, "FREQ=MONTHLY", "")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/calendar/events/"+strconv.FormatInt(id, 10), nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body eventEditJSONBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Summary != "Retro" || body.Location != "Room 3" || body.Recurrence != "monthly" {
t.Fatalf("unexpected prefill body: %+v", body)
}
}
// TestWebmailCalendarEventScopedToOwnMailbox confirms one mailbox owner can't
// read/delete another mailbox's event by guessing its id.
func TestWebmailCalendarEventScopedToOwnMailbox(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
victimID := createTestMailboxWithPassword(t, app, "calvictim2@example.com", domains[0].ID, "victim-password-1!")
attackerID := createTestMailboxWithPassword(t, app, "calattacker2@example.com", domains[0].ID, "attacker-password-1!")
start := time.Date(2026, 3, 10, 14, 0, 0, 0, time.UTC)
eventID, err := app.DB.CreateEvent(victimID, "Private", "", "", start, start.Add(time.Hour), false, "", "")
if err != nil {
t.Fatal(err)
}
attackerCookie := webmailLoginSession(t, app, attackerID)
getReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/calendar/events/"+strconv.FormatInt(eventID, 10), nil)
getReq.AddCookie(attackerCookie)
getRec := httptest.NewRecorder()
mux.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusNotFound {
t.Fatalf("expected 404 reading another mailbox's event, got %d", getRec.Code)
}
delReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/calendar/events/"+strconv.FormatInt(eventID, 10)+"/delete", nil)
delReq.AddCookie(attackerCookie)
delRec := httptest.NewRecorder()
mux.ServeHTTP(delRec, delReq)
if delRec.Code != http.StatusFound {
t.Fatalf("status=%d", delRec.Code)
}
stillThere, err := app.DB.GetEventByID(victimID, eventID)
if err != nil || stillThere == nil {
t.Fatalf("expected the victim's event untouched, got %+v (err=%v)", stillThere, err)
}
}
// TestWebmailCalendarEventReminderScopedToOwnMailbox confirms an attacker submitting
// another mailbox's event id can't attach/overwrite or delete that victim's reminder,
// even though the event row itself was already known to stay untouched (see
// TestWebmailCalendarEventScopedToOwnMailbox). Regression test for the IDOR where
// SetEventReminder/DeleteEvent's reminder-delete were scoped by event_id alone, with
// no mailbox_id check, letting the "save"/"delete" handlers act on a reminder for an
// event id belonging to a different mailbox entirely.
func TestWebmailCalendarEventReminderScopedToOwnMailbox(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
victimID := createTestMailboxWithPassword(t, app, "calvictim3@example.com", domains[0].ID, "victim-password-1!")
attackerID := createTestMailboxWithPassword(t, app, "calattacker3@example.com", domains[0].ID, "attacker-password-1!")
start := time.Date(2026, 3, 10, 14, 0, 0, 0, time.UTC)
eventID, err := app.DB.CreateEvent(victimID, "Private", "", "", start, start.Add(time.Hour), false, "", "")
if err != nil {
t.Fatal(err)
}
victimReminder := 30
if err := app.DB.SetEventReminder(victimID, eventID, &victimReminder); err != nil {
t.Fatal(err)
}
attackerCookie := webmailLoginSession(t, app, attackerID)
// Attacker "saves" the victim's event id with a reminder — must not touch it.
form := url.Values{
"id": {strconv.FormatInt(eventID, 10)}, "summary": {"Private"},
"start": {"2026-03-10T14:00"}, "end": {"2026-03-10T15:00"}, "reminder": {"5"},
}
saveReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/calendar/events/save", strings.NewReader(form.Encode()))
saveReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
saveReq.AddCookie(attackerCookie)
saveRec := httptest.NewRecorder()
mux.ServeHTTP(saveRec, saveReq)
got, err := app.DB.GetEventByID(victimID, eventID)
if err != nil || got == nil || got.ReminderMinutes == nil || *got.ReminderMinutes != 30 {
t.Fatalf("expected the victim's reminder untouched at 30, got %+v (err=%v)", got, err)
}
// Attacker "deletes" the victim's event id — reminder must survive.
delReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/calendar/events/"+strconv.FormatInt(eventID, 10)+"/delete", nil)
delReq.AddCookie(attackerCookie)
delRec := httptest.NewRecorder()
mux.ServeHTTP(delRec, delReq)
got, err = app.DB.GetEventByID(victimID, eventID)
if err != nil || got == nil || got.ReminderMinutes == nil || *got.ReminderMinutes != 30 {
t.Fatalf("expected the victim's reminder still untouched at 30 after attacker's delete attempt, got %+v (err=%v)", got, err)
}
}
func TestWebmailCalendarPageRenders(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "calpage@example.com", domains[0].ID, "calpage-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/calendar", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "calViewContainer") {
t.Errorf("expected calendar grid markup in rendered page")
}
}
// TestWebmailCalendarEventSaveColor confirms a submitted color is stored and returned
// via the events-in-range JSON, and an invalid/missing color falls back to the default
// rather than being rejected or stored as garbage.
func TestWebmailCalendarEventSaveColor(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithPassword(t, app, "calcolorweb@example.com", domains[0].ID, "calcolor-password-1!")
cookie := webmailLoginSession(t, app, mailboxID)
save := func(summary, color string) {
form := url.Values{
"id": {""}, "summary": {summary}, "start": {"2026-03-10T14:00"}, "end": {"2026-03-10T15:00"},
"all_day": {"0"}, "recurrence": {"none"}, "reminder": {""}, "color": {color},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/calendar/events/save", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("save %s: status=%d body=%s", summary, rec.Code, rec.Body.String())
}
}
save("Pink Event", "#e83e8c")
save("Bad Color Event", "not-a-color")
save("No Color Event", "")
events, err := app.DB.ListAllEvents(mailboxID)
if err != nil || len(events) != 3 {
t.Fatalf("expected 3 events, got %+v (err=%v)", events, err)
}
got := map[string]string{}
for _, e := range events {
got[e.Summary] = e.Color
}
if got["Pink Event"] != "#e83e8c" {
t.Errorf("valid color = %q, want #e83e8c", got["Pink Event"])
}
if got["Bad Color Event"] != calendarDefaultColor {
t.Errorf("invalid color = %q, want fallback %q", got["Bad Color Event"], calendarDefaultColor)
}
if got["No Color Event"] != calendarDefaultColor {
t.Errorf("empty color = %q, want fallback %q", got["No Color Event"], calendarDefaultColor)
}
// And the events-in-range JSON reflects the same color.
req := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/calendar/events?start=2026-03-01&end=2026-04-01", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
var jsonEvents []calendarEventJSON
if err := json.Unmarshal(rec.Body.Bytes(), &jsonEvents); err != nil {
t.Fatal(err)
}
for _, e := range jsonEvents {
if e.Summary == "Pink Event" && e.Color != "#e83e8c" {
t.Errorf("JSON color = %q, want #e83e8c", e.Color)
}
}
}