oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/base64"
7 "encoding/json"
8 "net/http"
9 "net/http/httptest"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "strconv"
14 "strings"
15 "testing"
16 "time"
17)
18
19func TestNativeCalendarRoundTrip(t *testing.T) {
20 f := newHomeRouterFixture(t)
21 call := func(method, path, body string) *httptest.ResponseRecorder {
22 r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(body))
23 r.Header.Set("Content-Type", "application/json")
24 r.Header.Set("Origin", homeTestOrigin)
25 for _, c := range f.cookies {
26 r.AddCookie(c)
27 }
28 w := httptest.NewRecorder()
29 f.app.mux.ServeHTTP(w, r)
30 return w
31 }
32 if call("GET", "/api/native/session", "").Code == 200 {
33 t.Fatal("anonymous native session")
34 }
35 f.local(t)
36 f.cookies = nativeClientCookies(t, f.app.mux, f.cookies)
37 var session struct {
38 Default int64 `json:"default_calendar_id"`
39 }
40 w := call("GET", "/api/native/session", "")
41 if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &session) != nil || session.Default == 0 {
42 t.Fatal("missing personal calendar", w.Code)
43 }
44 event := nativeEvent{CalendarID: session.Default, Title: "Native roundtrip", TZ: "Europe/Dublin", StartLocal: "2026-10-24T09:00", EndLocal: "2026-10-24T10:00"}
45 body, _ := json.Marshal(event)
46 w = call("POST", "/api/native/events", string(body))
47 if w.Code != 200 {
48 t.Fatal("create failed", w.Code, w.Body.String())
49 }
50 w = call("GET", "/api/native/events?from=2026-10-01&to=2026-11-01", "")
51 var result struct{ Events []nativeEvent }
52 if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || len(result.Events) != 1 {
53 t.Fatal("list failed", w.Code, w.Body.String())
54 }
55 got := result.Events[0]
56 if got.StartLocal != event.StartLocal || got.TZ != event.TZ || got.CalendarID != session.Default {
57 t.Fatal("wall clock or personal calendar changed")
58 }
59 event.AllDay = true
60 event.StartLocal = "2026-10-25"
61 event.EndLocal = "2026-10-26"
62 body, _ = json.Marshal(event)
63 if w = call("POST", "/api/native/events", string(body)); w.Code != 200 {
64 t.Fatal("all-day create failed", w.Code, w.Body.String())
65 }
66 if binary := os.Getenv("NATIVE_REVIEW_BINARY"); binary != "" || os.Getenv("NATIVE_REVIEW_SIMULATOR") != "" {
67 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
68 // The fixture's canonical test origin differs from its ephemeral listener.
69 // This adapter is exclusively in tests; real origin enforcement is covered above.
70 r.Header.Set("Origin", homeTestOrigin)
71 r.Host = strings.TrimPrefix(homeTestOrigin, "https://")
72 r.URL.Scheme = "https"
73 r.URL.Host = r.Host
74 f.app.mux.ServeHTTP(w, r)
75 }))
76 defer server.Close()
77 var credential map[string]string
78 for _, c := range f.cookies {
79 if strings.Contains(c.Name, "session") {
80 credential = map[string]string{"name": c.Name, "value": c.Value}
81 }
82 }
83 if credential == nil {
84 t.Fatal("fixture session missing")
85 }
86 input, _ := json.Marshal(map[string]any{"origin": server.URL, "credential": credential, "output": os.Getenv("NATIVE_REVIEW_OUTPUT")})
87 if simulator := os.Getenv("NATIVE_REVIEW_SIMULATOR"); simulator != "" {
88 bundle := "net.amadan.oficina.calendar.review"
89 run := func(args ...string) []byte {
90 t.Helper()
91 out, err := exec.CommandContext(t.Context(), "xcrun", append([]string{"simctl"}, args...)...).CombinedOutput()
92 if err != nil {
93 t.Fatalf("simulator: %v: %s", err, out)
94 }
95 return out
96 }
97 container := strings.TrimSpace(string(run("get_app_container", simulator, bundle, "data")))
98 path := filepath.Join(container, "Documents", "review.json")
99 if err := os.WriteFile(path, input, 0600); err != nil {
100 t.Fatal(err)
101 }
102 defer os.Remove(path)
103
104 if os.Getenv("NATIVE_REVIEW_UI_TESTS") != "" {
105 command := exec.CommandContext(t.Context(), "xcodebuild", "-project", "native/ios/CalendarPhone.xcodeproj", "-scheme", "CalendarReviewPhone", "-destination", "platform=iOS Simulator,id="+simulator, "-parallel-testing-enabled", "NO", "-derivedDataPath", "native/build/ios", "CODE_SIGNING_ALLOWED=NO", "test")
106 command.Dir = "../.."
107 output, err := command.CombinedOutput()
108 _ = os.WriteFile(filepath.Join(os.TempDir(), "calendar-native-ui-tests.log"), output, 0600)
109 if err != nil {
110 t.Fatal("Native UI tests failed; see temporary xcodebuild log", err)
111 }
112 }
113 run("launch", simulator, bundle)
114 time.Sleep(3 * time.Second)
115 run("io", simulator, "screenshot", os.Getenv("NATIVE_REVIEW_OUTPUT"))
116 run("terminate", simulator, bundle)
117 } else {
118 cmd := exec.CommandContext(t.Context(), binary)
119 cmd.Stdin = bytes.NewReader(input)
120 if output, err := cmd.CombinedOutput(); err != nil {
121 t.Fatalf("native review: %v: %s", err, output)
122 }
123 }
124
125 }
126
127 if call("GET", "/api/native/events?from=2026-01-01&to=2027-01-01", "").Code != 400 {
128 t.Fatal("unbounded range")
129 }
130 event.CalendarID = 999999
131 body, _ = json.Marshal(event)
132 if call("POST", "/api/native/events", string(body)).Code != 404 {
133 t.Fatal("foreign calendar did not fail closed")
134 }
135}
136
137// Independent review selected read-only and recurrent-write attempts.
138func TestNativeCalendarWriteGuards(t *testing.T) {
139 f := newHomeRouterFixture(t)
140 f.local(t)
141 alice, err := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test")
142 if err != nil {
143 t.Fatal(err)
144 }
145 bob, err := f.app.store.EnsurePersonSetup(t.Context(), "replacement@example.test")
146 if err != nil {
147 t.Fatal(err)
148 }
149 cal, err := f.app.store.DefaultCalendarID(t.Context(), bob.ID)
150 if err != nil {
151 t.Fatal(err)
152 }
153 ev, err := f.app.store.CreateEvent(t.Context(), bob.ID, EventInput{CalendarID: cal, Title: "Original", TZ: "Europe/Dublin", StartLocal: "2026-10-24T09:00", EndLocal: "2026-10-24T10:00", Kind: KindMeeting, Status: StatusConfirmed})
154 if err != nil {
155 t.Fatal(err)
156 }
157 attempt := func(id int64) *httptest.ResponseRecorder {
158 body, _ := json.Marshal(nativeEvent{Title: "Changed", CalendarID: cal, TZ: ev.TZ, StartLocal: ev.StartLocal, EndLocal: ev.EndLocal})
159 r := httptest.NewRequest("POST", homeTestOrigin+"/api/native/events/"+strconv.FormatInt(id, 10), bytes.NewReader(body))
160 r.Header.Set("Content-Type", "application/json")
161 r.Header.Set("Origin", homeTestOrigin)
162 for _, c := range f.cookies {
163 r.AddCookie(c)
164 }
165 w := httptest.NewRecorder()
166 f.app.mux.ServeHTTP(w, r)
167 return w
168 }
169 if attempt(ev.ID).Code != 404 {
170 t.Fatal("ungranted write accepted")
171 }
172 if err = f.app.store.ensureGrant(t.Context(), cal, alice.ID, RoleViewer, bob.ID); err != nil {
173 t.Fatal(err)
174 }
175 if w := attempt(ev.ID); w.Code != 403 && w.Code != 404 {
176 t.Fatal("viewer write accepted", w.Code)
177 }
178 unchanged, err := f.app.store.Event(t.Context(), bob.ID, ev.ID)
179 if err != nil || unchanged.Title != "Original" {
180 t.Fatal("denied write changed event")
181 }
182 own, err := f.app.store.DefaultCalendarID(t.Context(), alice.ID)
183 if err != nil {
184 t.Fatal(err)
185 }
186 recurring, err := f.app.store.CreateEvent(t.Context(), alice.ID, EventInput{CalendarID: own, Title: "Series", TZ: ev.TZ, StartLocal: ev.StartLocal, EndLocal: ev.EndLocal, Kind: KindMeeting, Status: StatusConfirmed, RRule: "FREQ=DAILY;COUNT=3"})
187 if err != nil {
188 t.Fatal(err)
189 }
190 if attempt(recurring.ID).Code != 409 {
191 t.Fatal("series write accepted")
192 }
193 unchanged, err = f.app.store.Event(t.Context(), alice.ID, recurring.ID)
194 if err != nil || unchanged.Title != "Series" || unchanged.RRule == "" {
195 t.Fatal("series changed")
196 }
197}
198
199func nativeClientCookies(t *testing.T, handler http.Handler, browser map[string]*http.Cookie) map[string]*http.Cookie {
200 t.Helper()
201 verifier := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{42}, 32))
202 hash := sha256.Sum256([]byte(verifier))
203 call := func(path, body string, signed bool) *httptest.ResponseRecorder {
204 r := httptest.NewRequest("POST", homeTestOrigin+path, strings.NewReader(body))
205 r.Header.Set("Origin", homeTestOrigin)
206 r.Header.Set("Content-Type", "application/json")
207 if signed {
208 for _, c := range browser {
209 r.AddCookie(c)
210 }
211 }
212 w := httptest.NewRecorder()
213 handler.ServeHTTP(w, r)
214 return w
215 }
216 w := call("/native/request", `{"challenge":"`+base64.RawURLEncoding.EncodeToString(hash[:])+`"}`, false)
217 var pending struct{ ID string }
218 if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &pending) != nil || pending.ID == "" {
219 t.Fatal("native begin failed", w.Code)
220 }
221 if w = call("/native/authorize?id="+pending.ID, "", true); w.Code != 200 {
222 t.Fatal("native approval failed", w.Code)
223 }
224 w = call("/native/exchange", `{"id":"`+pending.ID+`","verifier":"`+verifier+`"}`, false)
225 var session struct{ Name, Value string }
226 if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &session) != nil || session.Value == "" {
227 t.Fatal("native exchange failed", w.Code)
228 }
229 return map[string]*http.Cookie{session.Name: {Name: session.Name, Value: session.Value}}
230}
231
232func TestNativeAllDayCivilRangeBoundaries(t *testing.T) {
233 f := newHomeRouterFixture(t)
234 f.local(t)
235 person, err := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test")
236 if err != nil {
237 t.Fatal(err)
238 }
239 if err = f.app.store.g.Model(&Person{}).Where("id = ?", person.ID).Update("zone", "America/Los_Angeles").Error; err != nil {
240 t.Fatal(err)
241 }
242 cal, err := f.app.store.DefaultCalendarID(t.Context(), person.ID)
243 if err != nil {
244 t.Fatal(err)
245 }
246 for _, zone := range []string{"UTC", "Pacific/Kiritimati"} {
247 if _, err = f.app.store.CreateEvent(t.Context(), person.ID, EventInput{CalendarID: cal, Title: "Civil day", AllDay: true, StartLocal: "2026-09-12", EndLocal: "2026-09-13", TZ: zone, Kind: KindMeeting, Status: StatusConfirmed, RRule: "FREQ=DAILY;COUNT=2"}); err != nil {
248 t.Fatal(err)
249 }
250 }
251 for _, day := range []string{"12", "13"} {
252 next := "13"
253 if day == "13" {
254 next = "14"
255 }
256 w := f.do("GET", "/api/native/events?from=2026-09-"+day+"&to=2026-09-"+next, "")
257 var got struct{ Events []nativeEvent }
258 if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got.Events) != 2 {
259 t.Fatal("civil boundary lost or duplicated events", w.Code, w.Body.String())
260 }
261 for _, ev := range got.Events {
262 if ev.DayStart != "2026-09-"+day || ev.DayEnd != "2026-09-"+next {
263 t.Fatal("wrong civil occurrence dates")
264 }
265 }
266 }
267}
268