package web import ( "net/http" "net/http/httptest" "net/url" "strings" "testing" "git.arnef.de/arnef/nidus/internal/db" ) // seedEvent stores an all-day event (one-day span) in owner's calendar cal // under object id, with the given summary/location/description. Used to set // up events that handleEventView should render. func seedEvent(t *testing.T, s *Server, owner, cal, id, summary, location, description string) { t.Helper() data := "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//nidus//test//EN\r\n" + "BEGIN:VEVENT\r\n" + "UID:" + strings.TrimSuffix(id, ".ics") + "\r\n" + "DTSTAMP:20260101T000000Z\r\n" + "SUMMARY:" + summary + "\r\n" + "LOCATION:" + location + "\r\n" + "DESCRIPTION:" + description + "\r\n" + "DTSTART;VALUE=DATE:20260805\r\n" + "DTEND;VALUE=DATE:20260806\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" if err := s.store.PutObject(owner, "cal-"+cal, id, []byte(data)); err != nil { t.Fatalf("PutObject: %v", err) } } // getEventDetail issues GET /calendar/{ref}/{id} as the session identified by // cookie and returns the recorded response. func getEventDetail(t *testing.T, handler http.Handler, cookie *http.Cookie, ref, id string) *httptest.ResponseRecorder { t.Helper() path := "/calendar/" + url.PathEscape(ref) + "/" + url.PathEscape(id) req := httptest.NewRequest(http.MethodGet, path, nil) req.AddCookie(cookie) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) return rr } func TestEventDetailShowsOwnEvent(t *testing.T) { s := newTestServer(t) handler := s.Handler(emptyStaticFS{}) cookie := loginAs(t, handler, "alice", "password") id := "aabbccddeeff.ics" seedEvent(t, s, "alice", "work", id, "Team standup", "Meetroom A", "Daily sync with the team") rr := getEventDetail(t, handler, cookie, "work", id) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() for _, want := range []string{ "Team standup", // summary / title "Meetroom A", // location "Daily sync with the team", // description "work", // calendar label ">Edit", // writable → Edit link present "Export .ics", } { if !strings.Contains(body, want) { t.Errorf("expected body to contain %q, got:\n%s", want, body) } } } func TestEventDetailReadOnlySharedHasNoEdit(t *testing.T) { s := newTestServer(t) handler := s.Handler(emptyStaticFS{}) aliceCookie := loginAs(t, handler, "alice", "password") bobCookie := loginAs(t, handler, "bob", "password") id := "123456.ics" seedEvent(t, s, "bob", "personal", id, "Bob lunch", "Cafe", "Lunch plans") if err := s.dbase.ShareCalendar("bob", "personal", "alice", db.PermRead); err != nil { t.Fatalf("ShareCalendar: %v", err) } // alice (read share) can view but not edit. rr := getEventDetail(t, handler, aliceCookie, "bob~personal", id) if rr.Code != http.StatusOK { t.Fatalf("alice view: expected 200, got %d: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() if !strings.Contains(body, "Bob lunch") { t.Errorf("alice: expected body to contain summary, got:\n%s", body) } if !strings.Contains(body, "shared with you as read-only") { t.Errorf("alice: expected read-only notice, got:\n%s", body) } if strings.Contains(body, ">Edit") { t.Errorf("alice: Edit link should not be present on a read-only share, got:\n%s", body) } // bob (owner) can still see the Edit link. rrBob := getEventDetail(t, handler, bobCookie, "personal", id) if rrBob.Code != http.StatusOK { t.Fatalf("bob view own: expected 200, got %d", rrBob.Code) } if !strings.Contains(rrBob.Body.String(), ">Edit") { t.Errorf("bob: expected Edit link, got:\n%s", rrBob.Body.String()) } } func TestEventDetailWriteShareHasEditLink(t *testing.T) { s := newTestServer(t) handler := s.Handler(emptyStaticFS{}) cookie := loginAs(t, handler, "alice", "password") seedEvent(t, s, "bob", "personal", "a1b2c3.ics", "Bob meeting", "Office", "Sync") if err := s.dbase.ShareCalendar("bob", "personal", "alice", db.PermWrite); err != nil { t.Fatalf("ShareCalendar: %v", err) } rr := getEventDetail(t, handler, cookie, "bob~personal", "a1b2c3.ics") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() if !strings.Contains(body, ">Edit") { t.Errorf("write share should expose Edit link, got:\n%s", body) } if strings.Contains(body, "shared with you as read-only") { t.Errorf("write share should not show read-only notice, got:\n%s", body) } } func TestEventDetailNotFound(t *testing.T) { s := newTestServer(t) handler := s.Handler(emptyStaticFS{}) cookie := loginAs(t, handler, "alice", "password") // Valid calendar, missing object. if rr := getEventDetail(t, handler, cookie, "work", "doesnotexist.ics"); rr.Code != http.StatusNotFound { t.Fatalf("missing event: expected 404, got %d", rr.Code) } // Unknown calendar ref. if rr := getEventDetail(t, handler, cookie, "does_not_exist", "0000.ics"); rr.Code != http.StatusNotFound { t.Fatalf("unknown calendar: expected 404, got %d", rr.Code) } // Invalid id shape (rejected by eventIDRe before store access). if rr := getEventDetail(t, handler, cookie, "work", "bad/../etc/passwd.ics"); rr.Code != http.StatusNotFound { t.Fatalf("invalid id: expected 404, got %d", rr.Code) } } func TestEventDetailRequiresLogin(t *testing.T) { s := newTestServer(t) handler := s.Handler(emptyStaticFS{}) req := httptest.NewRequest(http.MethodGet, "/calendar/work/anything.ics", nil) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != http.StatusSeeOther { t.Fatalf("expected redirect to login, got %d", rr.Code) } } func TestEventDetailRejectsNonGet(t *testing.T) { s := newTestServer(t) handler := s.Handler(emptyStaticFS{}) cookie := loginAs(t, handler, "alice", "password") req := httptest.NewRequest(http.MethodPost, "/calendar/work/anything.ics", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != http.StatusMethodNotAllowed { t.Fatalf("expected 405 for POST, got %d", rr.Code) } } // TestEditRouteStillResolves guards against the new detail route // (/calendar/{ref}/{id}) shadowing the more specific edit/delete/export // routes under the same {ref}+{id} prefix in Go's ServeMux. func TestEditRouteStillResolves(t *testing.T) { s := newTestServer(t) handler := s.Handler(emptyStaticFS{}) cookie := loginAs(t, handler, "alice", "password") id := "cafebabe00.ics" seedEvent(t, s, "alice", "work", id, "Edit me", "Room", "Note") // GET the edit form — must still hit handleEventEdit, not the detail view. req := httptest.NewRequest(http.MethodGet, "/calendar/work/cafebabe00.ics/edit", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("edit GET: expected 200, got %d: %s", rr.Code, rr.Body.String()) } if !strings.Contains(rr.Body.String(), "Edit event") || !strings.Contains(rr.Body.String(), "