210 lines
7.4 KiB
Go
210 lines
7.4 KiB
Go
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(), "<form") {
|
|
t.Fatalf("expected the edit form to render, got:\n%s", rr.Body.String())
|
|
}
|
|
|
|
// The delete route still works (redirect to /web/calendar on success).
|
|
req = httptest.NewRequest(http.MethodPost, "/calendar/work/cafebabe00.ics/delete", nil)
|
|
req.AddCookie(cookie)
|
|
rr = httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusSeeOther {
|
|
t.Fatalf("delete: expected 303 redirect, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|