eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// Tests for the mini-app server surface (miniapps.go): scheduled "future
4// messages" and per-thread state snapshots. Delivery is exercised by calling
5// sweepDueScheduled directly — the ticker only runs in main().
6
7import (
8 "context"
9 "encoding/json"
10 "fmt"
11 "net/http"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/coder/websocket"
17)
18
19func postJSON(t *testing.T, url, body string) *http.Response {
20 t.Helper()
21 resp, err := http.Post(url, "application/json", strings.NewReader(body))
22 if err != nil {
23 t.Fatalf("POST %s: %v", url, err)
24 }
25 return resp
26}
27
28func doReq(t *testing.T, method, url, body string) *http.Response {
29 t.Helper()
30 var rd *strings.Reader
31 if body == "" {
32 rd = strings.NewReader("")
33 } else {
34 rd = strings.NewReader(body)
35 }
36 req, _ := http.NewRequest(method, url, rd)
37 if body != "" {
38 req.Header.Set("Content-Type", "application/json")
39 }
40 resp, err := http.DefaultClient.Do(req)
41 if err != nil {
42 t.Fatalf("%s %s: %v", method, url, err)
43 }
44 return resp
45}
46
47func decodeJSON(t *testing.T, resp *http.Response, into any) {
48 t.Helper()
49 defer resp.Body.Close()
50 if err := json.NewDecoder(resp.Body).Decode(into); err != nil {
51 t.Fatalf("decode: %v", err)
52 }
53}
54
55// Scheduling is member-gated and validates its inputs.
56func TestScheduleAuthAndValidation(t *testing.T) {
57 ts, srv := newTestServer(t)
58 tok, _, tid := joinedMember(t, srv, "Sam")
59 future := time.Now().Add(time.Hour).Unix()
60
61 // Bad token → 403.
62 if r := postJSON(t, ts.URL+"/api/schedule", fmt.Sprintf(`{"token":"bogus","thread_id":"%s","deliver_at":%d,"blob":"x"}`, tid, future)); r.StatusCode != http.StatusForbidden {
63 t.Fatalf("bad token: want 403, got %d", r.StatusCode)
64 }
65 // Not a member of the thread → 403.
66 tok2, _, _ := joinedMember(t, srv, "Rival")
67 if r := postJSON(t, ts.URL+"/api/schedule", fmt.Sprintf(`{"token":"%s","thread_id":"%s","deliver_at":%d,"blob":"x"}`, tok2, tid, future)); r.StatusCode != http.StatusForbidden {
68 t.Fatalf("non-member: want 403, got %d", r.StatusCode)
69 }
70 // Empty blob → 400.
71 if r := postJSON(t, ts.URL+"/api/schedule", fmt.Sprintf(`{"token":"%s","thread_id":"%s","deliver_at":%d,"blob":""}`, tok, tid, future)); r.StatusCode != http.StatusBadRequest {
72 t.Fatalf("empty blob: want 400, got %d", r.StatusCode)
73 }
74 // deliver_at too far out (past the horizon) → 400.
75 tooFar := time.Now().Add(scheduleHorizon + 24*time.Hour).Unix()
76 if r := postJSON(t, ts.URL+"/api/schedule", fmt.Sprintf(`{"token":"%s","thread_id":"%s","deliver_at":%d,"blob":"x"}`, tok, tid, tooFar)); r.StatusCode != http.StatusBadRequest {
77 t.Fatalf("past horizon: want 400, got %d", r.StatusCode)
78 }
79 // deliver_at deep in the past → 400 (a minute of skew is tolerated).
80 if r := postJSON(t, ts.URL+"/api/schedule", fmt.Sprintf(`{"token":"%s","thread_id":"%s","deliver_at":%d,"blob":"x"}`, tok, tid, time.Now().Add(-time.Hour).Unix())); r.StatusCode != http.StatusBadRequest {
81 t.Fatalf("deep past: want 400, got %d", r.StatusCode)
82 }
83 // Valid → 200 with an id.
84 r := postJSON(t, ts.URL+"/api/schedule", fmt.Sprintf(`{"token":"%s","thread_id":"%s","deliver_at":%d,"blob":"ciphertext"}`, tok, tid, future))
85 if r.StatusCode != http.StatusOK {
86 t.Fatalf("valid schedule: want 200, got %d", r.StatusCode)
87 }
88 var out struct {
89 ID string `json:"id"`
90 }
91 decodeJSON(t, r, &out)
92 if out.ID == "" {
93 t.Fatal("schedule returned no id")
94 }
95
96 // The pending schedule is listable by a member, not by a non-member.
97 r, _ = http.Get(ts.URL + "/api/schedule?token=" + tok + "&thread=" + tid)
98 var list struct {
99 Schedules []ScheduledMessage `json:"schedules"`
100 }
101 decodeJSON(t, r, &list)
102 if len(list.Schedules) != 1 || list.Schedules[0].ID != out.ID || list.Schedules[0].Blob != "ciphertext" {
103 t.Fatalf("list: %+v", list.Schedules)
104 }
105 if r, _ = http.Get(ts.URL + "/api/schedule?token=" + tok2 + "&thread=" + tid); r.StatusCode != http.StatusForbidden {
106 t.Fatalf("non-member list: want 403, got %d", r.StatusCode)
107 }
108}
109
110// A due schedule moves into the message log exactly once; an undue one stays.
111func TestScheduleDelivery(t *testing.T) {
112 ts, srv := newTestServer(t)
113 tok, _, tid := joinedMember(t, srv, "Sam")
114
115 // One due now (grace window lets a just-past time in), one due in an hour.
116 now := time.Now().Unix()
117 r := postJSON(t, ts.URL+"/api/schedule", fmt.Sprintf(`{"token":"%s","thread_id":"%s","deliver_at":%d,"blob":"due-blob"}`, tok, tid, now-1))
118 if r.StatusCode != http.StatusOK {
119 t.Fatalf("schedule due: want 200, got %d", r.StatusCode)
120 }
121 postJSON(t, ts.URL+"/api/schedule", fmt.Sprintf(`{"token":"%s","thread_id":"%s","deliver_at":%d,"blob":"later-blob"}`, tok, tid, now+3600))
122
123 if n := srv.sweepDueScheduled(); n != 1 {
124 t.Fatalf("sweep: want 1 delivered, got %d", n)
125 }
126 // Delivered blob is now a normal message in the thread.
127 msgs, err := srv.db.messagesAfter(tid, 0, 10)
128 if err != nil || len(msgs) != 1 || msgs[0].Blob != "due-blob" {
129 t.Fatalf("messages after sweep: %v %+v", err, msgs)
130 }
131 // Sweep again: nothing due — no duplicate delivery.
132 if n := srv.sweepDueScheduled(); n != 0 {
133 t.Fatalf("second sweep: want 0, got %d", n)
134 }
135 if msgs, _ = srv.db.messagesAfter(tid, 0, 10); len(msgs) != 1 {
136 t.Fatalf("after second sweep: want 1 message, got %d", len(msgs))
137 }
138 // The undue schedule is still pending.
139 pending, _ := srv.db.listScheduledMessages(tid)
140 if len(pending) != 1 || pending[0].Blob != "later-blob" {
141 t.Fatalf("pending after sweep: %+v", pending)
142 }
143}
144
145// The activator's due-time probe: /api/next-due is admin-gated and reports the
146// earliest pending deliver_at (0 when nothing is scheduled) so the router can
147// wake a hibernating instance in time to fire it.
148func TestNextDue(t *testing.T) {
149 ts, srv := newTestServer(t)
150 tok, _, tid := joinedMember(t, srv, "Sam")
151
152 getDue := func(bearer string) (int, int64) {
153 t.Helper()
154 req, _ := http.NewRequest("GET", ts.URL+"/api/next-due", nil)
155 if bearer != "" {
156 req.Header.Set("Authorization", "Bearer "+bearer)
157 }
158 resp, err := http.DefaultClient.Do(req)
159 if err != nil {
160 t.Fatalf("GET /api/next-due: %v", err)
161 }
162 if resp.StatusCode != http.StatusOK {
163 resp.Body.Close()
164 return resp.StatusCode, 0
165 }
166 var body struct {
167 Due int64 `json:"due"`
168 }
169 decodeJSON(t, resp, &body)
170 return resp.StatusCode, body.Due
171 }
172
173 // No auth → 401; a plain member's device token isn't admin either.
174 if code, _ := getDue(""); code != http.StatusUnauthorized {
175 t.Fatalf("unauthenticated: want 401, got %d", code)
176 }
177 if code, _ := getDue(tok); code != http.StatusUnauthorized {
178 t.Fatalf("member token: want 401, got %d", code)
179 }
180
181 // Nothing scheduled → 0.
182 if code, due := getDue("admintok"); code != 200 || due != 0 {
183 t.Fatalf("empty schedule: want 200/0, got %d/%d", code, due)
184 }
185
186 // The earliest of several pending schedules wins, regardless of post order.
187 now := time.Now().Unix()
188 later, sooner := now+7200, now+3600
189 postJSON(t, ts.URL+"/api/schedule", fmt.Sprintf(`{"token":"%s","thread_id":"%s","deliver_at":%d,"blob":"later"}`, tok, tid, later))
190 postJSON(t, ts.URL+"/api/schedule", fmt.Sprintf(`{"token":"%s","thread_id":"%s","deliver_at":%d,"blob":"sooner"}`, tok, tid, sooner))
191 if _, due := getDue("admintok"); due != sooner {
192 t.Fatalf("due = %d, want the earlier %d", due, sooner)
193 }
194
195 // Delivery consumes the row, so the next due time advances.
196 pending, _ := srv.db.listScheduledMessages(tid)
197 if err := srv.db.deleteScheduledMessage(pending[0].ID); err != nil { // pending is deliver_at-ordered: [0] = sooner
198 t.Fatalf("delete: %v", err)
199 }
200 if _, due := getDue("admintok"); due != later {
201 t.Fatalf("after consuming the sooner schedule: due = %d, want %d", due, later)
202 }
203}
204
205// combineNextDue: the MIN composition behind /api/next-due, and the staleness
206// rule that keeps a dead sidecar's report from wake-looping a hibernating
207// instance.
208func TestCombineNextDue(t *testing.T) {
209 const now = int64(1_000_000)
210 cases := []struct {
211 name string
212 sched, charter, want int64
213 }{
214 {"neither", 0, 0, 0},
215 {"only messages", now + 100, 0, now + 100},
216 {"only charter", 0, now + 200, now + 200},
217 {"both, schedule sooner", now + 100, now + 200, now + 100},
218 {"both, charter sooner", now + 300, now + 200, now + 200},
219 // Staleness: a past charter report is ignored — it changes only when
220 // the sidecar re-reports, so honoring it would re-wake the instance
221 // after every sleep, forever (at-most-one wasted wake, never a loop).
222 {"past charter ignored", 0, now - 10, 0},
223 {"past charter never shadows the queue", now + 100, now - 10, now + 100},
224 {"charter due exactly now still fires", 0, now, now},
225 // The asymmetry: a past SCHEDULE deliver_at is honored — delivery
226 // consumes the row, so waking for it is right and self-limiting.
227 {"past schedule honored", now - 10, 0, now - 10},
228 }
229 for _, c := range cases {
230 if got := combineNextDue(c.sched, c.charter, now); got != c.want {
231 t.Errorf("%s: combineNextDue(%d, %d) = %d, want %d", c.name, c.sched, c.charter, got, c.want)
232 }
233 }
234}
235
236// /api/next-due folds the sidecar-reported charter due-time in with the
237// schedule queue — the endpoint-level view of combineNextDue.
238func TestNextDueFoldsCharterDue(t *testing.T) {
239 ts, srv := newTestServer(t)
240 tok, _, tid := joinedMember(t, srv, "Sam")
241
242 getDue := func() int64 {
243 t.Helper()
244 req, _ := http.NewRequest("GET", ts.URL+"/api/next-due", nil)
245 req.Header.Set("Authorization", "Bearer admintok")
246 resp, err := http.DefaultClient.Do(req)
247 if err != nil || resp.StatusCode != http.StatusOK {
248 t.Fatalf("GET /api/next-due: %v %d", err, resp.StatusCode)
249 }
250 var body struct {
251 Due int64 `json:"due"`
252 }
253 decodeJSON(t, resp, &body)
254 return body.Due
255 }
256
257 now := time.Now().Unix()
258
259 // Only a charter report → it is the answer.
260 if err := srv.db.setCharterNextDue(now + 600); err != nil {
261 t.Fatal(err)
262 }
263 if due := getDue(); due != now+600 {
264 t.Fatalf("charter only: due = %d, want %d", due, now+600)
265 }
266
267 // A sooner scheduled message wins; a later one doesn't.
268 postJSON(t, ts.URL+"/api/schedule", fmt.Sprintf(`{"token":"%s","thread_id":"%s","deliver_at":%d,"blob":"x"}`, tok, tid, now+300))
269 if due := getDue(); due != now+300 {
270 t.Fatalf("sooner schedule: due = %d, want %d", due, now+300)
271 }
272 if err := srv.db.setCharterNextDue(now + 60); err != nil {
273 t.Fatal(err)
274 }
275 if due := getDue(); due != now+60 {
276 t.Fatalf("sooner charter: due = %d, want %d", due, now+60)
277 }
278
279 // A stale (past) charter report is ignored — the queue's answer stands.
280 if err := srv.db.setCharterNextDue(now - 60); err != nil {
281 t.Fatal(err)
282 }
283 if due := getDue(); due != now+300 {
284 t.Fatalf("stale charter: due = %d, want %d", due, now+300)
285 }
286}
287
288// Cancellation is member-gated; cancelling an already-fired/unknown id is quiet.
289func TestScheduleCancel(t *testing.T) {
290 ts, srv := newTestServer(t)
291 tok, _, tid := joinedMember(t, srv, "Sam")
292 tok2, _, _ := joinedMember(t, srv, "Rival")
293 future := time.Now().Add(time.Hour).Unix()
294
295 r := postJSON(t, ts.URL+"/api/schedule", fmt.Sprintf(`{"token":"%s","thread_id":"%s","deliver_at":%d,"blob":"x"}`, tok, tid, future))
296 var out struct {
297 ID string `json:"id"`
298 }
299 decodeJSON(t, r, &out)
300
301 // A non-member may not cancel someone else's schedule.
302 if r := doReq(t, "DELETE", ts.URL+"/api/schedule/"+out.ID+"?token="+tok2, ""); r.StatusCode != http.StatusForbidden {
303 t.Fatalf("non-member cancel: want 403, got %d", r.StatusCode)
304 }
305 // A member may.
306 if r := doReq(t, "DELETE", ts.URL+"/api/schedule/"+out.ID+"?token="+tok, ""); r.StatusCode != http.StatusOK {
307 t.Fatalf("member cancel: want 200, got %d", r.StatusCode)
308 }
309 if pending, _ := srv.db.listScheduledMessages(tid); len(pending) != 0 {
310 t.Fatalf("after cancel: %+v", pending)
311 }
312 // Cancelled schedules never deliver.
313 if n := srv.sweepDueScheduled(); n != 0 {
314 t.Fatalf("sweep after cancel: want 0, got %d", n)
315 }
316 // Cancelling again (already gone) is quiet success — but still needs a token.
317 if r := doReq(t, "DELETE", ts.URL+"/api/schedule/"+out.ID+"?token="+tok, ""); r.StatusCode != http.StatusOK {
318 t.Fatalf("re-cancel: want 200, got %d", r.StatusCode)
319 }
320 if r := doReq(t, "DELETE", ts.URL+"/api/schedule/"+out.ID+"?token=bogus", ""); r.StatusCode != http.StatusForbidden {
321 t.Fatalf("re-cancel bad token: want 403, got %d", r.StatusCode)
322 }
323}
324
325// A real DB failure while resolving the schedule's thread must surface as 5xx,
326// never a false 200 — otherwise the client believes the reminder was cancelled
327// while it still fires. Dropping the table forces a non-ErrNoRows lookup error.
328func TestScheduleCancelDBError(t *testing.T) {
329 ts, srv := newTestServer(t)
330 tok, _, tid := joinedMember(t, srv, "Sam")
331 future := time.Now().Add(time.Hour).Unix()
332
333 r := postJSON(t, ts.URL+"/api/schedule", fmt.Sprintf(`{"token":"%s","thread_id":"%s","deliver_at":%d,"blob":"x"}`, tok, tid, future))
334 var out struct {
335 ID string `json:"id"`
336 }
337 decodeJSON(t, r, &out)
338
339 if _, err := srv.db.sql.Exec("DROP TABLE scheduled_messages"); err != nil {
340 t.Fatalf("drop table: %v", err)
341 }
342 if r := doReq(t, "DELETE", ts.URL+"/api/schedule/"+out.ID+"?token="+tok, ""); r.StatusCode != http.StatusInternalServerError {
343 t.Fatalf("cancel during db error: want 500, got %d", r.StatusCode)
344 }
345}
346
347// State snapshots: member-gated read/write with optimistic versioning.
348func TestThreadState(t *testing.T) {
349 ts, srv := newTestServer(t)
350 tok, _, tid := joinedMember(t, srv, "Sam")
351 tok2, _, _ := joinedMember(t, srv, "Rival")
352 stateURL := ts.URL + "/api/threads/" + tid + "/state"
353
354 // Nothing saved yet: version 0, empty blob.
355 r, _ := http.Get(stateURL + "?token=" + tok + "&slot=apps")
356 var st struct {
357 Blob string `json:"blob"`
358 Version int64 `json:"version"`
359 }
360 decodeJSON(t, r, &st)
361 if st.Version != 0 || st.Blob != "" {
362 t.Fatalf("fresh state: %+v", st)
363 }
364
365 // Create (version 0 → 1).
366 r = doReq(t, "PUT", stateURL, fmt.Sprintf(`{"token":"%s","slot":"apps","blob":"snap1","version":0}`, tok))
367 var put struct {
368 Version int64 `json:"version"`
369 }
370 if r.StatusCode != http.StatusOK {
371 t.Fatalf("create state: want 200, got %d", r.StatusCode)
372 }
373 decodeJSON(t, r, &put)
374 if put.Version != 1 {
375 t.Fatalf("create version: want 1, got %d", put.Version)
376 }
377
378 // A stale writer (still claiming version 0) conflicts.
379 if r = doReq(t, "PUT", stateURL, fmt.Sprintf(`{"token":"%s","slot":"apps","blob":"snap-stale","version":0}`, tok)); r.StatusCode != http.StatusConflict {
380 t.Fatalf("stale write: want 409, got %d", r.StatusCode)
381 }
382 // The current version updates fine (1 → 2).
383 if r = doReq(t, "PUT", stateURL, fmt.Sprintf(`{"token":"%s","slot":"apps","blob":"snap2","version":1}`, tok)); r.StatusCode != http.StatusOK {
384 t.Fatalf("update state: want 200, got %d", r.StatusCode)
385 }
386
387 // Read back: latest blob + version.
388 r, _ = http.Get(stateURL + "?token=" + tok + "&slot=apps")
389 decodeJSON(t, r, &st)
390 if st.Blob != "snap2" || st.Version != 2 {
391 t.Fatalf("read back: %+v", st)
392 }
393
394 // Non-members can neither read nor write.
395 if r, _ = http.Get(stateURL + "?token=" + tok2 + "&slot=apps"); r.StatusCode != http.StatusForbidden {
396 t.Fatalf("non-member read: want 403, got %d", r.StatusCode)
397 }
398 if r = doReq(t, "PUT", stateURL, fmt.Sprintf(`{"token":"%s","slot":"apps","blob":"evil","version":2}`, tok2)); r.StatusCode != http.StatusForbidden {
399 t.Fatalf("non-member write: want 403, got %d", r.StatusCode)
400 }
401}
402
403// An ephemeral msg frame is relayed to the thread's members but never stored:
404// presence traffic (typing, live cursors) must leave no trace in the log.
405func TestEphemeralRelayNotStored(t *testing.T) {
406 ts, srv := newTestServer(t)
407 tokA, uidA, _ := joinedMember(t, srv, "Alice")
408 tokB, uidB, _ := joinedMember(t, srv, "Bob")
409 tid, err := srv.db.createThread("group", "", []ThreadMemberKey{{UserID: uidA, Wrapped: "wa"}, {UserID: uidB, Wrapped: "wb"}})
410 if err != nil {
411 t.Fatalf("createThread: %v", err)
412 }
413
414 wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?token="
415 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
416 defer cancel()
417 dial := func(tok string) *websocket.Conn {
418 c, _, err := websocket.Dial(ctx, wsURL+tok, nil)
419 if err != nil {
420 t.Fatalf("ws dial: %v", err)
421 }
422 return c
423 }
424 connA := dial(tokA)
425 defer connA.CloseNow()
426 connB := dial(tokB)
427 defer connB.CloseNow()
428 // Registration races the first send (the hub processes register/broadcast on
429 // one loop, but Dial returns before the server's register lands). A stored
430 // message round-trip on A both proves the pipe and orders B's registration.
431 if err := connA.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"warmup"}`)); err != nil {
432 t.Fatalf("warmup write: %v", err)
433 }
434 if _, _, err := connA.Read(ctx); err != nil {
435 t.Fatalf("warmup read: %v", err)
436 }
437
438 // A sends an ephemeral frame; B must receive it flagged, with no id.
439 if err := connA.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"eph-blob","ephemeral":true}`)); err != nil {
440 t.Fatalf("ephemeral write: %v", err)
441 }
442 // B first drains the warmup broadcast, then gets the ephemeral frame.
443 var got wsOut
444 for i := 0; i < 3; i++ {
445 _, data, err := connB.Read(ctx)
446 if err != nil {
447 t.Fatalf("B read: %v", err)
448 }
449 if err := json.Unmarshal(data, &got); err != nil {
450 t.Fatalf("B unmarshal: %v", err)
451 }
452 if got.Ephemeral {
453 break
454 }
455 }
456 if !got.Ephemeral || got.Blob != "eph-blob" || got.ID != 0 {
457 t.Fatalf("ephemeral frame: %+v", got)
458 }
459 // Only the warmup message was stored — the ephemeral one left no trace.
460 msgs, err := srv.db.messagesAfter(tid, 0, 10)
461 if err != nil || len(msgs) != 1 || msgs[0].Blob != "warmup" {
462 t.Fatalf("stored messages: %v %+v", err, msgs)
463 }
464}
465