oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1package calls
2
3import (
4 "crypto/sha256"
5 "encoding/base64"
6 "encoding/json"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "strings"
11 "testing"
12 "time"
13
14 "amadan.net/oficina/calls/protocol"
15 "amadan.net/oficina/home/connect"
16)
17
18func protocolCode(id string) string { return protocol.AuthenticationCode(id) }
19
20func nativeJSON(f *homeRouterFixture, path string, input any) *httptest.ResponseRecorder {
21 b, _ := json.Marshal(input)
22 r := httptest.NewRequest("POST", homeTestOrigin+path, strings.NewReader(string(b)))
23 r.Header.Set("Content-Type", "application/json")
24 w := httptest.NewRecorder()
25 f.handler.ServeHTTP(w, r)
26 return w
27}
28func nativeBegin(t *testing.T, f *homeRouterFixture) (string, string) {
29 t.Helper()
30 v := nativeRandom()
31 h := sha256.Sum256([]byte(v))
32 w := nativeJSON(f, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(h[:]), "target": "/m/abc"})
33 var out map[string]string
34 if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &out) != nil || !nativeToken(out["id"]) {
35 t.Fatal("request failed", w.Code)
36 }
37 return out["id"], v
38}
39func TestNativeHandoffPreservesHomeIdentityAndRequiresProof(t *testing.T) {
40 f := newHomeRouterFixture(t)
41 f.link(t)
42 id, v := nativeBegin(t, f)
43 exchange := func(verifier string) *httptest.ResponseRecorder {
44 return nativeJSON(f, "/native/exchange", map[string]string{"id": id, "verifier": verifier})
45 }
46 if w := exchange(v); w.Code != 202 {
47 t.Fatal("not pending", w.Code)
48 }
49 if w := f.do("GET", "/native/authorize?id="+id, ""); w.Code != 200 || !strings.Contains(w.Body.String(), "Sign in to Calls app") {
50 t.Fatal("missing consent", w.Code)
51 }
52 if w := exchange(v); w.Code != 202 {
53 t.Fatal("GET authorized", w.Code)
54 }
55 // Ambient browser cookies alone cannot approve from a foreign origin.
56 r := httptest.NewRequest("POST", homeTestOrigin+"/native/authorize?id="+id, nil)
57 for _, c := range f.cookies {
58 r.AddCookie(c)
59 }
60 r.Header.Set("Origin", "https://attacker.test")
61 w := httptest.NewRecorder()
62 f.handler.ServeHTTP(w, r)
63 if w.Code != 403 {
64 t.Fatal("CSRF accepted", w.Code)
65 }
66 if w := f.do("POST", "/native/authorize?id="+id, ""); w.Code != 303 {
67 t.Fatal("approve", w.Code)
68 }
69 if w := exchange(nativeRandom()); w.Code != 404 {
70 t.Fatal("wrong proof accepted", w.Code)
71 }
72 w = exchange(v)
73 if w.Code != 200 {
74 t.Fatal("exchange", w.Code)
75 }
76 var result map[string]string
77 json.Unmarshal(w.Body.Bytes(), &result)
78 req := httptest.NewRequest("GET", homeTestOrigin+"/", nil)
79 req.AddCookie(&http.Cookie{Name: result["name"], Value: result["value"]})
80 session, ok := f.app.nativeAuth.sessions.From(req)
81 if !ok || session.Method != connect.HomeMethod {
82 t.Fatal("lost Home identity")
83 }
84 browser := httptest.NewRequest("GET", homeTestOrigin+"/", nil)
85 for _, c := range f.cookies {
86 browser.AddCookie(c)
87 }
88 original, _ := f.app.nativeAuth.sessions.From(browser)
89 if original.Subject != session.Subject || !original.AuthTime.Equal(session.AuthTime) || result["value"] == f.cookies[result["name"]].Value {
90 t.Fatal("handoff changed identity or reused browser credential")
91 }
92 if exchange(v).Code != 404 {
93 t.Fatal("replay accepted")
94 }
95}
96func TestNativeHandoffEmailContinuationAndRevocation(t *testing.T) {
97 f := newHomeRouterFixture(t)
98 id, v := nativeBegin(t, f)
99 if dest := homeRedirect(t, f.do("GET", "/native/start?id="+id, "")); dest != "/m/abc/join" {
100 t.Fatal(dest)
101 }
102 f.local(t) // follows the emailed link in the browser, keeping its pending cookie
103 if dest := homeRedirect(t, f.do("GET", "/", "")); dest != "/native/authorize?id="+id {
104 t.Fatal("email lost handoff", dest)
105 }
106 if _, ok := f.cookies[f.app.nativePendingCookie()]; ok {
107 t.Fatal("pending cookie retained")
108 }
109 f.do("POST", "/native/authorize?id="+id, "")
110 f.do("POST", "/signout", "")
111 if w := nativeJSON(f, "/native/exchange", map[string]string{"id": id, "verifier": v}); w.Code != 401 {
112 t.Fatal("revoked browser granted session", w.Code)
113 }
114}
115func TestNativeHandoffExpiryAndTargetValidation(t *testing.T) {
116 f := newHomeRouterFixture(t)
117 id, v := nativeBegin(t, f)
118 f.app.nativeAuth.pending[id].Expires = time.Now().Add(-time.Second)
119 if w := nativeJSON(f, "/native/exchange", map[string]string{"id": id, "verifier": v}); w.Code != 404 {
120 t.Fatal("expired accepted")
121 }
122 for _, target := range []string{"https://evil.test/m/abc", "//evil.test/m/abc", "/j/../signin", "/m/a?next=evil", "/m/%2f%2fevil", "/m/a#evil", "m/a"} {
123 if nativeTarget(target) {
124 t.Errorf("unsafe target %q", target)
125 }
126 }
127}
128
129func TestNativeHandoffResumesAfterHomeAssertion(t *testing.T) {
130 f := newHomeRouterFixture(t)
131 f.link(t)
132 f.do("POST", "/signout", "")
133 id, _ := nativeBegin(t, f)
134 homeRedirect(t, f.do("GET", "/native/start?id="+id, ""))
135 signin := homeRedirect(t, f.do("GET", "/m/abc/join", ""))
136 start := homeRedirect(t, f.do("GET", signin, ""))
137 destination := homeRedirect(t, f.exchange(t, start, "opaque-home-alice"))
138 if destination != "/" && destination != "/m/abc/join" {
139 t.Fatal("unexpected Home destination", destination)
140 }
141 if got := homeRedirect(t, f.do("GET", destination, "")); got != "/native/authorize?id="+id {
142 t.Fatal("Home lost native continuation", got)
143 }
144}
145
146func TestNativeHandoffPreservesAnonymousGuest(t *testing.T) {
147 f := newHomeRouterFixture(t)
148 m, token, err := f.app.store.createMeeting(t.Context(), "Guest call", nil, "person:1")
149 if err != nil {
150 t.Fatal(err)
151 }
152 if w := f.do("POST", "/j/"+token+"/guest", "name=Visitor"); w.Code != 303 {
153 t.Fatal(w.Code, w.Body.String())
154 }
155 id, v := nativeBegin(t, f)
156 if w := f.do("GET", "/native/authorize?id="+id, ""); w.Code != 200 || !strings.Contains(w.Body.String(), "Continue as guest") {
157 t.Fatal(w.Code)
158 }
159 if w := f.do("POST", "/native/authorize?id="+id, ""); w.Code != 303 {
160 t.Fatal(w.Code)
161 }
162 w := nativeJSON(f, "/native/exchange", map[string]string{"id": id, "verifier": v})
163 if w.Code != 200 {
164 t.Fatal(w.Code, w.Body.String())
165 }
166 var result map[string]string
167 if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
168 t.Fatal(err)
169 }
170 f.cookies = map[string]*http.Cookie{result["name"]: {Name: result["name"], Value: result["value"]}}
171 if w := f.do("GET", "/m/"+m.Sqid+"/join", ""); w.Code != 200 {
172 t.Fatal("native guest cannot join", w.Code)
173 }
174 if w := f.do("GET", "/settings", ""); w.Code != 404 {
175 t.Fatal("native guest gained member authority", w.Code)
176 }
177}
178
179func TestNativeGuestInvitationResumesAuthorizationWithoutSignin(t *testing.T) {
180 f := newHomeRouterFixture(t)
181 m, token, err := f.app.store.createMeeting(t.Context(), "Native guest call", nil, "person:1")
182 if err != nil {
183 t.Fatal(err)
184 }
185 verifier := nativeRandom()
186 challenge := sha256.Sum256([]byte(verifier))
187 w := nativeJSON(f, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(challenge[:]), "target": "/j/" + token})
188 var request map[string]string
189 if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &request) != nil {
190 t.Fatal(w.Code)
191 }
192 if dest := homeRedirect(t, f.do("GET", "/native/start?id="+request["id"], "")); dest != "/j/"+token {
193 t.Fatal(dest)
194 }
195 if w := f.do("GET", "/j/"+token, ""); w.Code != 200 {
196 t.Fatal("guest invitation required sign-in", w.Code)
197 }
198 if dest := homeRedirect(t, f.do("POST", "/j/"+token+"/guest", "name=Visitor")); dest != "/m/"+m.Sqid+"/join" {
199 t.Fatal(dest)
200 }
201 if dest := homeRedirect(t, f.do("GET", "/m/"+m.Sqid+"/join", "")); dest != "/native/authorize?id="+request["id"] {
202 t.Fatal("guest lost native handoff", dest)
203 }
204}
205
206// nativeMemberJSON is nativeJSON with the session and the origin: the
207// start endpoint runs on ambient authority, unlike /native/request and
208// /native/exchange, so both of the locks it sits behind have to be in
209// the request for the handler to be reached at all.
210func nativeMemberJSON(f *homeRouterFixture, path, body string) *httptest.ResponseRecorder {
211 r := httptest.NewRequest("POST", homeTestOrigin+path, strings.NewReader(body))
212 r.Header.Set("Content-Type", "application/json")
213 r.Header.Set("Origin", homeTestOrigin)
214 for _, c := range f.cookies {
215 r.AddCookie(c)
216 }
217 w := httptest.NewRecorder()
218 f.handler.ServeHTTP(w, r)
219 return w
220}
221
222func TestNativeStartMeetingAnswersASqidAMemberCanOpen(t *testing.T) {
223 f := newHomeRouterFixture(t)
224 f.local(t)
225 w := nativeMemberJSON(f, "/native/meetings/start", "{}")
226 if w.Code != 200 {
227 t.Fatal("start refused", w.Code, w.Body.String())
228 }
229 if got := w.Header().Get("Cache-Control"); got != "no-store" {
230 t.Fatal("meeting identifier is cacheable", got)
231 }
232 var out map[string]string
233 if json.Unmarshal(w.Body.Bytes(), &out) != nil || !validSqid(out["sqid"]) {
234 t.Fatal("no usable sqid", w.Body.String())
235 }
236 if out["title"] == "" {
237 t.Fatal("no title")
238 }
239 // The sqid is only worth answering if it opens: the app's next act
240 // is to join it, on the same session.
241 if got := f.do("GET", "/m/"+out["sqid"]+"/join", ""); got.Code != 200 {
242 t.Fatal("started meeting will not open", got.Code)
243 }
244}
245
246func TestNativeStartMeetingRefusesWithoutBothLocks(t *testing.T) {
247 f := newHomeRouterFixture(t)
248 f.local(t)
249 // A form content type is the cross-site shape: an HTML form can send
250 // it, and nothing else about the request would stop it.
251 form := httptest.NewRequest("POST", homeTestOrigin+"/native/meetings/start", strings.NewReader(""))
252 form.Header.Set("Content-Type", "application/x-www-form-urlencoded")
253 form.Header.Set("Origin", homeTestOrigin)
254 for _, c := range f.cookies {
255 form.AddCookie(c)
256 }
257 w := httptest.NewRecorder()
258 f.handler.ServeHTTP(w, form)
259 if w.Code != http.StatusUnsupportedMediaType {
260 t.Fatal("form post accepted", w.Code)
261 }
262 // And no origin at all is csrf.Protect's refusal, before the handler.
263 bare := httptest.NewRequest("POST", homeTestOrigin+"/native/meetings/start", strings.NewReader("{}"))
264 bare.Header.Set("Content-Type", "application/json")
265 for _, c := range f.cookies {
266 bare.AddCookie(c)
267 }
268 w = httptest.NewRecorder()
269 f.handler.ServeHTTP(w, bare)
270 if w.Code == 200 {
271 t.Fatal("origin-less post accepted")
272 }
273 var count int
274 if err := f.app.db.Writer().QueryRow("SELECT count(*) FROM meetings").Scan(&count); err != nil {
275 t.Fatal(err)
276 }
277 if count != 0 {
278 t.Fatal("a refused request still created a meeting", count)
279 }
280}
281
282func TestNativeStartMeetingRefusesASignedOutApp(t *testing.T) {
283 f := newHomeRouterFixture(t)
284 if w := nativeMemberJSON(f, "/native/meetings/start", "{}"); w.Code == 200 {
285 t.Fatal("signed-out start accepted")
286 }
287}
288
289func TestNativeTargetAcceptsTheMeetingListAndNothingElseUnpathed(t *testing.T) {
290 // "/" is the app signing in to an instance rather than to a meeting:
291 // the person who has no link because the meeting does not exist yet.
292 if !nativeTarget("/") {
293 t.Fatal("the meeting list is not a target")
294 }
295 for _, target := range []string{"", "//", "/?next=evil", "/#evil", "/welcome", "/settings", "/signin"} {
296 if nativeTarget(target) {
297 t.Errorf("unsafe or unintended target %q", target)
298 }
299 }
300}
301
302func TestNativeHandoffWithoutAMeetingLandsOnTheList(t *testing.T) {
303 f := newHomeRouterFixture(t)
304 f.local(t)
305 v := nativeRandom()
306 h := sha256.Sum256([]byte(v))
307 w := nativeJSON(f, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(h[:]), "target": "/"})
308 var out map[string]string
309 if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &out) != nil {
310 t.Fatal("request refused", w.Code)
311 }
312 id := out["id"]
313 // No /join is appended to the list the way it is to a meeting.
314 if got := homeRedirect(t, f.do("GET", "/native/start?id="+id, "")); got != "/" {
315 t.Fatal("did not land on the list", got)
316 }
317 // And the resume middleware carries an already-signed-in person
318 // straight to the consent screen from "/", which is the whole point
319 // of the list being a target at all.
320 if got := homeRedirect(t, f.do("GET", "/", "")); got != "/native/authorize?id="+id {
321 t.Fatal("no continuation from the list", got)
322 }
323 if page := f.do("GET", "/native/authorize?id="+id, ""); page.Code != 200 {
324 t.Fatal("no consent screen", page.Code)
325 }
326 homeRedirect(t, f.do("POST", "/native/authorize?id="+id, ""))
327 ex := nativeJSON(f, "/native/exchange", map[string]string{"id": id, "verifier": v})
328 if ex.Code != 200 {
329 t.Fatal("exchange refused", ex.Code, ex.Body.String())
330 }
331 var session map[string]string
332 if json.Unmarshal(ex.Body.Bytes(), &session) != nil || session["target"] != "/" {
333 t.Fatal("target not carried back", ex.Body.String())
334 }
335}
336
337// The app keeps a member's native session across launches and reads
338// three things off this server by status alone (callscore.CheckSession,
339// OpenLink and SignOut): /settings for whether the session is still a
340// member's, /j/{token} for the meeting an invitation opens, and
341// /signout for revoking it. None of them is a native endpoint, so this
342// is where a change to any of those answers is caught before it signs
343// every app out, or never does.
344func TestNativeSessionReadsWhatTheAppReads(t *testing.T) {
345 f := newHomeRouterFixture(t)
346 f.local(t)
347 m, token, err := f.app.store.createMeeting(t.Context(), "Kept session call", nil, "person:1")
348 if err != nil {
349 t.Fatal(err)
350 }
351 browser := httptest.NewRequest("GET", homeTestOrigin+"/", nil)
352 for _, c := range f.cookies {
353 browser.AddCookie(c)
354 }
355 session, ok := f.app.nativeAuth.sessions.From(browser)
356 if !ok {
357 t.Fatal("no browser session to hand over")
358 }
359 value, err := f.app.nativeAuth.sessions.Mint(session)
360 if err != nil {
361 t.Fatal(err)
362 }
363 // From here on the fixture is the app: its own session and nothing
364 // the browser had.
365 name := f.app.nativeAuth.sessions.CookieName()
366 f.cookies = map[string]*http.Cookie{name: {Name: name, Value: value}}
367 if w := f.do("GET", "/settings", ""); w.Code != 200 {
368 t.Fatal("a member's native session is not read as one", w.Code)
369 }
370 if dest := homeRedirect(t, f.do("GET", "/j/"+token, "")); dest != "/m/"+m.Sqid+"/join" {
371 t.Fatal("invitation did not lead to its meeting", dest)
372 }
373 homeSignout(t, f.do("POST", "/signout", ""))
374 // f.do dropped the cleared cookie; the app still holds its copy.
375 f.cookies = map[string]*http.Cookie{name: {Name: name, Value: value}}
376 if dest := homeRedirect(t, f.do("GET", "/settings", "")); !strings.HasPrefix(dest, signinPath) {
377 t.Fatal("a revoked session is not sent to sign in", dest)
378 }
379 if w := f.do("GET", "/j/"+token, ""); w.Code != 200 && !strings.HasPrefix(w.Header().Get("Location"), signinPath) {
380 t.Fatal("a revoked session followed an invitation", w.Code, w.Header().Get("Location"))
381 }
382}
383
384// The web's "Join in the app" (2026-10-02): a guest who gave only a name
385// in this browser is handed to the app as themselves, approved here,
386// with no sign-in — the case the /m/ link could never do, because the
387// app's own browser had no session for a guest and no way to make one.
388func TestJoinInTheAppHandsThisBrowsersGuestToTheApp(t *testing.T) {
389 f := newHomeRouterFixture(t)
390 m, token, err := f.app.store.createMeeting(t.Context(), "Guest call", nil, "person:1")
391 if err != nil {
392 t.Fatal(err)
393 }
394 if w := f.do("POST", "/j/"+token+"/guest", "name=Visitor"); w.Code != 303 {
395 t.Fatal(w.Code, w.Body.String())
396 }
397 holding := f.do("GET", "/m/"+m.Sqid+"/join", "").Body.String()
398 if !strings.Contains(holding, `href="/m/`+m.Sqid+`/app"`) {
399 t.Fatal("the holding screen's Join in the app is not the handoff")
400 }
401 page := homeRedirect(t, f.do("GET", "/m/"+m.Sqid+"/app", ""))
402 offer := strings.TrimPrefix(page, "/native/offer?o=")
403 if !nativeToken(offer) {
404 t.Fatal("no offer", page)
405 }
406 w := f.do("GET", page, "")
407 if w.Code != 200 || !strings.Contains(w.Body.String(), "oficina-calls://join?url="+url.QueryEscape(homeTestOrigin+"/m/"+m.Sqid)+"&offer="+offer) {
408 t.Fatal("the waiting page does not open the app with the offer", w.Code, w.Body.String())
409 }
410 if got := w.Header().Get("Cache-Control"); got != "no-store" {
411 t.Fatal("cacheable", got)
412 }
413 // Somebody else's browser cannot watch the offer.
414 other := httptest.NewRequest("GET", homeTestOrigin+page, nil)
415 rec := httptest.NewRecorder()
416 f.handler.ServeHTTP(rec, other)
417 if rec.Code == 200 && strings.Contains(rec.Body.String(), offer) {
418 t.Fatal("another browser sees the offer")
419 }
420 claim := func(target, offer string) (*httptest.ResponseRecorder, string) {
421 v := nativeRandom()
422 h := sha256.Sum256([]byte(v))
423 return nativeJSON(f, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(h[:]), "target": target, "offer": offer}), v
424 }
425 if w, _ := claim("/m/another", offer); w.Code != http.StatusGone {
426 t.Fatal("an offer was claimed for another meeting", w.Code)
427 }
428 w, verifier := claim("/m/"+m.Sqid, offer)
429 var request struct {
430 ID string `json:"id"`
431 Offer bool `json:"offer"`
432 }
433 if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &request) != nil || !request.Offer {
434 t.Fatal("claim", w.Code, w.Body.String())
435 }
436 if w, _ := claim("/m/"+m.Sqid, offer); w.Code != http.StatusGone {
437 t.Fatal("an offer was claimed twice", w.Code)
438 }
439 if dest := homeRedirect(t, f.do("GET", page, "")); dest != "/native/authorize?id="+request.ID {
440 t.Fatal("the waiting page did not become the confirmation", dest)
441 }
442 poll := httptest.NewRequest("GET", homeTestOrigin+page, nil)
443 poll.Header.Set("Accept", "application/json")
444 for _, c := range f.cookies {
445 poll.AddCookie(c)
446 }
447 rec = httptest.NewRecorder()
448 f.handler.ServeHTTP(rec, poll)
449 if !strings.Contains(rec.Body.String(), `"next":"/native/authorize?id=`+request.ID+`"`) {
450 t.Fatal("the poll did not name the confirmation", rec.Body.String())
451 }
452 if w := f.do("GET", "/native/authorize?id="+request.ID, ""); w.Code != 200 || !strings.Contains(w.Body.String(), protocolCode(request.ID)) {
453 t.Fatal("confirmation", w.Code)
454 }
455 if w := nativeJSON(f, "/native/exchange", map[string]string{"id": request.ID, "verifier": verifier}); w.Code != 202 {
456 t.Fatal("released before approval", w.Code)
457 }
458 f.do("POST", "/native/authorize?id="+request.ID, "")
459 w = nativeJSON(f, "/native/exchange", map[string]string{"id": request.ID, "verifier": verifier})
460 var result map[string]string
461 if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || result["target"] != "/m/"+m.Sqid {
462 t.Fatal("exchange", w.Code, w.Body.String())
463 }
464 f.cookies = map[string]*http.Cookie{result["name"]: {Name: result["name"], Value: result["value"]}}
465 if w := f.do("GET", "/m/"+m.Sqid+"/info", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"name":"Visitor"`) {
466 t.Fatal("the app is not the same guest in the same meeting", w.Code, w.Body.String())
467 }
468 if w := f.do("GET", "/settings", ""); w.Code != 404 {
469 t.Fatal("the guest gained member authority", w.Code)
470 }
471}
472
473// An offer expires with the five minutes every request has, and the page
474// says so rather than waiting for ever.
475func TestJoinInTheAppOfferExpires(t *testing.T) {
476 f := newHomeRouterFixture(t)
477 f.local(t)
478 m, _, err := f.app.store.createMeeting(t.Context(), "Expiry", nil, "person:1")
479 if err != nil {
480 t.Fatal(err)
481 }
482 page := homeRedirect(t, f.do("GET", "/m/"+m.Sqid+"/app", ""))
483 offer := strings.TrimPrefix(page, "/native/offer?o=")
484 f.app.nativeAuth.offers[offer].Expires = time.Now().Add(-time.Second)
485 if w := f.do("GET", page, ""); w.Code != 404 || !strings.Contains(w.Body.String(), "expired") {
486 t.Fatal("expired offer", w.Code)
487 }
488 v := nativeRandom()
489 h := sha256.Sum256([]byte(v))
490 if w := nativeJSON(f, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(h[:]), "target": "/m/" + m.Sqid, "offer": offer}); w.Code != http.StatusGone {
491 t.Fatal("an expired offer was claimed", w.Code)
492 }
493}
494
495// The apps' Start meeting names the device's zone, and the title is the
496// time there with no zone in the words (2026-10-02).
497func TestNativeStartMeetingTitlesInTheAppsZone(t *testing.T) {
498 f := newHomeRouterFixture(t)
499 f.local(t)
500 w := nativeMemberJSON(f, "/native/meetings/start", `{"zone":"Asia/Tokyo"}`)
501 var out map[string]string
502 if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &out) != nil {
503 t.Fatal(w.Code, w.Body.String())
504 }
505 tokyo, _ := time.LoadLocation("Asia/Tokyo")
506 if want := "Call, " + f.app.store.now().In(tokyo).Format("2 Jan 15:04"); out["title"] != want {
507 t.Fatalf("title %q, want %q", out["title"], want)
508 }
509 m, ok, err := f.app.store.meetingBySqid(t.Context(), out["sqid"])
510 if err != nil || !ok || !m.AutoTitle || m.TitleZone != "Asia/Tokyo" {
511 t.Fatalf("meeting %+v %v %v", m, ok, err)
512 }
513}
514