join: /j/, the pending-join cookie, ruling 12 and /continue
Following a live link sets a signed cookie and redirects to sign-in; the gate lets a non-member address through only with that cookie, within two budgets, and writes the pending row before Begin so the emailed link admits it anywhere; /continue re-checks the link and mints the grant in one transaction.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
9 files changed,
+465
−12
internal/meet/app.go+17 −7internal/meet/auth.go+53 −4internal/meet/budget.go+40 −0internal/meet/budget_test.go+23 −0internal/meet/handlers_join.go+43 −0internal/meet/joincookie.go+76 −0internal/meet/joincookie_test.go+39 −0internal/meet/store.go+34 −1internal/meettest/join_test.go+140 −0
diff --git a/internal/meet/app.go b/internal/meet/app.go| index 22dca09..3ec24e8 100644 |
| --- a/internal/meet/app.go |
| +++ b/internal/meet/app.go |
| @@ -8,6 +8,7 @@ import ( |
| "github.com/go-chi/chi/v5" |
| + rauth "amadan.net/rastrillo/rastrillo/auth" |
| "amadan.net/rastrillo/rastrillo/csrf" |
| "amadan.net/rastrillo/rastrillo/db" |
| "amadan.net/rastrillo/rastrillo/migrate" |
| @@ -17,11 +18,14 @@ import ( |
| // app holds what every handler needs. |
| type app struct { |
| - db *db.DB |
| - cfg resolved |
| - sealer *seal.Sealer |
| - store *store |
| - logger *slog.Logger |
| + db *db.DB |
| + cfg resolved |
| + sealer *seal.Sealer |
| + store *store |
| + logger *slog.Logger |
| + auth *rauth.Auth |
| + linkBudget *budget |
| + outsiderBudget *budget |
| } |
| // SetClockForTest replaces the store's clock; the harness uses it to |
| @@ -48,7 +52,11 @@ func build(d *db.DB, cfg Config) (*http.ServeMux, *app, error) { |
| "skipped", migrated.Skipped, "adopted", migrated.Adopted) |
| st := &store{g: d.G, sealer: sealer, now: time.Now} |
| - a := &app{db: d, cfg: rc, sealer: sealer, store: st, logger: rc.Logger} |
| + a := &app{ |
| + db: d, cfg: rc, sealer: sealer, store: st, logger: rc.Logger, |
| + linkBudget: newBudget(5, 15*time.Minute), |
| + outsiderBudget: newBudget(10, 15*time.Minute), |
| + } |
| writer, err := d.G.DB() |
| if err != nil { |
| @@ -58,16 +66,18 @@ func build(d *db.DB, cfg Config) (*http.ServeMux, *app, error) { |
| if err != nil { |
| return nil, nil, err |
| } |
| + a.auth = authPlugin |
| r := chi.NewRouter() |
| r.Use(csrf.Protect(rc.Origin)) |
| - // Inside CSRF, outside the session: sign-in and, in Task 6, /j/. |
| + // Inside CSRF, outside the session: sign-in and /j/. |
| r.Get(signinPath, a.signin) |
| r.Post(signinPath, a.gateBegin(authPlugin.Begin)) |
| r.Get("/auth/callback", authPlugin.Callback) |
| r.Get("/auth/verify", authPlugin.Verify) |
| r.Post("/signout", authPlugin.Signout) |
| + r.Get("/j/{token}", a.followLink) |
| // Signed in, with a persons row. |
| r.Group(func(r chi.Router) { |
diff --git a/internal/meet/auth.go b/internal/meet/auth.go| index cf80a13..7635394 100644 |
| --- a/internal/meet/auth.go |
| +++ b/internal/meet/auth.go |
| @@ -64,9 +64,42 @@ func (a *app) gateBegin(next http.HandlerFunc) http.HandlerFunc { |
| } |
| } |
| -// mayRequestLink decides whether Begin is reached. Task 6 extends it. |
| +// mayRequestLink decides whether Begin is reached (ruling 12). A member |
| +// address always may. Any other address may only when the browser |
| +// carries a valid pending-join cookie for a live link, within two |
| +// budgets counted before Begin: five attempts per link and ten across |
| +// all links per fifteen minutes. When it may, a pending_joins row is |
| +// written first, for the member too (it carries their destination). |
| func (a *app) mayRequestLink(w http.ResponseWriter, r *http.Request, address string) bool { |
| - return a.cfg.allow.allows(address) |
| + address = normalizeAddress(address) |
| + member := a.cfg.allow.allows(address) |
| + ck, hasCookie := a.readJoinCookie(r) |
| + if !hasCookie { |
| + return member |
| + } |
| + link, _, live, err := a.store.liveLinkByHash(r.Context(), ck.TokenHash) |
| + if err != nil { |
| + a.logger.Error("meet: link for cookie", "err", err) |
| + return member |
| + } |
| + if !live { |
| + return member |
| + } |
| + if !member { |
| + now := a.store.now() |
| + if !a.outsiderBudget.allow("all", now) || !a.linkBudget.allow(ck.TokenHash, now) { |
| + a.logger.Warn("meet: outsider sign-in attempts over budget", "link", ck.TokenHash[:8]) |
| + return false |
| + } |
| + } |
| + if address == "" { |
| + return member |
| + } |
| + if err := a.store.writePending(r.Context(), address, link); err != nil { |
| + a.logger.Error("meet: write pending join", "err", err) |
| + return member |
| + } |
| + return true |
| } |
| // rastrillo's own Begin also same-origin-checks the request and |
| @@ -165,9 +198,25 @@ func (a *app) continueSignedIn(w http.ResponseWriter, r *http.Request) { |
| http.Redirect(w, r, "/welcome", http.StatusSeeOther) |
| } |
| -// continuePending is filled in by Task 6. |
| +// continuePending is /continue's first branch (spec §3.2): the most |
| +// recent unexpired pending row for this person, re-checked against its |
| +// link and meeting in one transaction with the grant, then deleted. |
| func (a *app) continuePending(w http.ResponseWriter, r *http.Request, p Person) (string, bool) { |
| - return "", false |
| + a.clearJoinCookie(w) |
| + pend, ok, err := a.store.latestPending(r.Context(), p.Email) |
| + if err != nil { |
| + a.logger.Error("meet: latest pending", "err", err) |
| + return "", false |
| + } |
| + if !ok { |
| + return "", false |
| + } |
| + dest, err := a.store.consumePending(r.Context(), p, pend, a.isMember(p)) |
| + if err != nil { |
| + a.logger.Error("meet: consume pending", "err", err) |
| + return "", false |
| + } |
| + return dest, dest != "" |
| } |
| func (a *app) welcome(w http.ResponseWriter, r *http.Request) { |
diff --git a/internal/meet/budget.go b/internal/meet/budget.go| new file mode 100644 |
| index 0000000..746eb0c |
| --- /dev/null |
| +++ b/internal/meet/budget.go |
| @@ -0,0 +1,40 @@ |
| +package meet |
| + |
| +import ( |
| + "sync" |
| + "time" |
| +) |
| + |
| +// budget is a sliding-window counter: at most limit events per key per |
| +// window. It counts attempts, refused or not, because the point is to |
| +// keep outsider traffic away from rastrillo's shared limiter (spec |
| +// §3.2), and an attempt that reaches Begin is what consumes that. |
| +type budget struct { |
| + mu sync.Mutex |
| + limit int |
| + window time.Duration |
| + hits map[string][]time.Time |
| +} |
| + |
| +func newBudget(limit int, window time.Duration) *budget { |
| + return &budget{limit: limit, window: window, hits: map[string][]time.Time{}} |
| +} |
| + |
| +// allow records an attempt and reports whether it was within budget. |
| +func (b *budget) allow(key string, now time.Time) bool { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + cut := now.Add(-b.window) |
| + kept := b.hits[key][:0] |
| + for _, t := range b.hits[key] { |
| + if t.After(cut) { |
| + kept = append(kept, t) |
| + } |
| + } |
| + if len(kept) >= b.limit { |
| + b.hits[key] = kept |
| + return false |
| + } |
| + b.hits[key] = append(kept, now) |
| + return true |
| +} |
diff --git a/internal/meet/budget_test.go b/internal/meet/budget_test.go| new file mode 100644 |
| index 0000000..5ccd538 |
| --- /dev/null |
| +++ b/internal/meet/budget_test.go |
| @@ -0,0 +1,23 @@ |
| +package meet |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestBudgetSlidesItsWindow(t *testing.T) { |
| + b := newBudget(2, time.Minute) |
| + now := time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC) |
| + if !b.allow("k", now) || !b.allow("k", now) { |
| + t.Fatal("first two refused") |
| + } |
| + if b.allow("k", now) { |
| + t.Fatal("third allowed") |
| + } |
| + if !b.allow("other", now) { |
| + t.Fatal("other key refused") |
| + } |
| + if !b.allow("k", now.Add(61*time.Second)) { |
| + t.Fatal("refused after the window slid") |
| + } |
| +} |
diff --git a/internal/meet/handlers_join.go b/internal/meet/handlers_join.go| new file mode 100644 |
| index 0000000..80bcb2c |
| --- /dev/null |
| +++ b/internal/meet/handlers_join.go |
| @@ -0,0 +1,43 @@ |
| +package meet |
| + |
| +import ( |
| + "net/http" |
| + |
| + "github.com/go-chi/chi/v5" |
| +) |
| + |
| +// followLink is GET /j/{token}: one 404 for unknown, revoked, ended or |
| +// expired; otherwise one of three outcomes (spec §3.2). It is mounted |
| +// outside the session group because the person is often not signed in. |
| +func (a *app) followLink(w http.ResponseWriter, r *http.Request) { |
| + link, m, live, err := a.store.liveLink(r.Context(), chi.URLParam(r, "token")) |
| + if err != nil { |
| + a.logger.Error("meet: follow link", "err", err) |
| + ErrorPage(w, r, http.StatusInternalServerError, "") |
| + return |
| + } |
| + if !live { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| + id, signedIn := a.auth.SessionFrom(r) |
| + if !signedIn { |
| + a.setJoinCookie(w, link.TokenHash) |
| + http.Redirect(w, r, signinPath, http.StatusSeeOther) |
| + return |
| + } |
| + p, err := a.store.ensurePerson(r.Context(), id.Address) |
| + if err != nil { |
| + a.logger.Error("meet: ensure person", "err", err) |
| + ErrorPage(w, r, http.StatusInternalServerError, "") |
| + return |
| + } |
| + if !a.isMember(p) { |
| + if _, err := a.store.mintGrant(r.Context(), m, p.ID); err != nil { |
| + a.logger.Error("meet: mint grant", "err", err) |
| + ErrorPage(w, r, http.StatusInternalServerError, "") |
| + return |
| + } |
| + } |
| + http.Redirect(w, r, "/m/"+m.Sqid+"/join", http.StatusSeeOther) |
| +} |
diff --git a/internal/meet/joincookie.go b/internal/meet/joincookie.go| new file mode 100644 |
| index 0000000..3000c6b |
| --- /dev/null |
| +++ b/internal/meet/joincookie.go |
| @@ -0,0 +1,76 @@ |
| +package meet |
| + |
| +import ( |
| + "crypto/hmac" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "net/http" |
| + "strconv" |
| + "strings" |
| + "time" |
| +) |
| + |
| +const joinCookieName = "meet_join" |
| + |
| +// joinCookie tells POST /signin which live link this browser followed. |
| +// It carries the token's hash, not the token, so a cookie leak is not a |
| +// link leak, and it is signed under the instance key so it cannot be |
| +// forged. Its only job is that one hand-off (spec §3.2). |
| +type joinCookie struct { |
| + TokenHash string |
| + Expires time.Time |
| +} |
| + |
| +func (a *app) joinMAC(payload string) string { |
| + mac := hmac.New(sha256.New, []byte("meet/join\x00"+a.cfg.instanceKey)) |
| + mac.Write([]byte(payload)) |
| + return hex.EncodeToString(mac.Sum(nil)) |
| +} |
| + |
| +func (a *app) setJoinCookie(w http.ResponseWriter, tokenHash string) { |
| + exp := a.store.now().Add(pendingLife) |
| + payload := tokenHash + "|" + strconv.FormatInt(exp.Unix(), 10) |
| + value := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + a.joinMAC(payload) |
| + http.SetCookie(w, &http.Cookie{ |
| + Name: joinCookieName, Value: value, Path: "/", HttpOnly: true, |
| + Secure: strings.HasPrefix(a.cfg.Origin, "https://"), SameSite: http.SameSiteLaxMode, |
| + MaxAge: int(pendingLife.Seconds()), |
| + }) |
| +} |
| + |
| +func (a *app) clearJoinCookie(w http.ResponseWriter) { |
| + http.SetCookie(w, &http.Cookie{Name: joinCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) |
| +} |
| + |
| +func (a *app) readJoinCookie(r *http.Request) (joinCookie, bool) { |
| + ck, err := r.Cookie(joinCookieName) |
| + if err != nil { |
| + return joinCookie{}, false |
| + } |
| + parts := strings.SplitN(ck.Value, ".", 2) |
| + if len(parts) != 2 { |
| + return joinCookie{}, false |
| + } |
| + raw, err := base64.RawURLEncoding.DecodeString(parts[0]) |
| + if err != nil { |
| + return joinCookie{}, false |
| + } |
| + payload := string(raw) |
| + if !hmac.Equal([]byte(a.joinMAC(payload)), []byte(parts[1])) { |
| + return joinCookie{}, false |
| + } |
| + hash, expStr, ok := strings.Cut(payload, "|") |
| + if !ok || len(hash) != 64 { |
| + return joinCookie{}, false |
| + } |
| + exp, err := strconv.ParseInt(expStr, 10, 64) |
| + if err != nil { |
| + return joinCookie{}, false |
| + } |
| + c := joinCookie{TokenHash: hash, Expires: time.Unix(exp, 0)} |
| + if !c.Expires.After(a.store.now()) { |
| + return joinCookie{}, false |
| + } |
| + return c, true |
| +} |
diff --git a/internal/meet/joincookie_test.go b/internal/meet/joincookie_test.go| new file mode 100644 |
| index 0000000..32dc66b |
| --- /dev/null |
| +++ b/internal/meet/joincookie_test.go |
| @@ -0,0 +1,39 @@ |
| +package meet |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func cookieApp(now time.Time) *app { |
| + return &app{cfg: resolved{instanceKey: "k", Config: Config{Origin: "http://app.test"}}, store: &store{now: func() time.Time { return now }}} |
| +} |
| + |
| +func TestJoinCookieRoundTripsAndRejectsTampering(t *testing.T) { |
| + now := time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC) |
| + a := cookieApp(now) |
| + rec := httptest.NewRecorder() |
| + hash := strings.Repeat("ab", 32) |
| + a.setJoinCookie(rec, hash) |
| + ck := rec.Result().Cookies()[0] |
| + req := httptest.NewRequest(http.MethodGet, "/", nil) |
| + req.AddCookie(ck) |
| + got, ok := a.readJoinCookie(req) |
| + if !ok || got.TokenHash != hash { |
| + t.Fatalf("round trip failed: %+v %v", got, ok) |
| + } |
| + bad := *ck |
| + bad.Value = strings.Replace(bad.Value, ".", "x.", 1) |
| + req2 := httptest.NewRequest(http.MethodGet, "/", nil) |
| + req2.AddCookie(&bad) |
| + if _, ok := a.readJoinCookie(req2); ok { |
| + t.Fatal("tampered cookie accepted") |
| + } |
| + later := cookieApp(now.Add(pendingLife + time.Second)) |
| + if _, ok := later.readJoinCookie(req); ok { |
| + t.Fatal("expired cookie accepted") |
| + } |
| +} |
diff --git a/internal/meet/store.go b/internal/meet/store.go| index 0c8904c..e033444 100644 |
| --- a/internal/meet/store.go |
| +++ b/internal/meet/store.go |
| @@ -144,8 +144,12 @@ func (s *store) liveLink(ctx context.Context, token string) (MeetingLink, Meetin |
| if token == "" { |
| return MeetingLink{}, Meeting{}, false, nil |
| } |
| + return s.liveLinkByHash(ctx, hashLinkToken(token)) |
| +} |
| + |
| +func (s *store) liveLinkByHash(ctx context.Context, hash string) (MeetingLink, Meeting, bool, error) { |
| var l MeetingLink |
| - err := s.g.WithContext(ctx).Where("token_hash = ?", hashLinkToken(token)).Take(&l).Error |
| + err := s.g.WithContext(ctx).Where("token_hash = ?", hash).Take(&l).Error |
| if errors.Is(err, gorm.ErrRecordNotFound) { |
| return MeetingLink{}, Meeting{}, false, nil |
| } |
| @@ -289,3 +293,32 @@ func (s *store) deletePending(ctx context.Context, address string) error { |
| } |
| return s.g.WithContext(ctx).Where("address_fingerprint = ?", fp).Delete(&PendingJoin{}).Error |
| } |
| + |
| +// consumePending re-checks the pending row's link and meeting as |
| +// GET /j/{token} would, mints the grant for a non-member, and deletes |
| +// every pending row for the address, all in one transaction. It returns |
| +// the holding-screen path, or "" when the re-check failed. |
| +func (s *store) consumePending(ctx context.Context, p Person, pend PendingJoin, member bool) (string, error) { |
| + dest := "" |
| + err := s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + ts := &store{g: tx, sealer: s.sealer, now: s.now} |
| + if err := ts.deletePending(ctx, p.Email); err != nil { |
| + return err |
| + } |
| + if !pend.ExpiresAt.After(s.now()) { |
| + return nil |
| + } |
| + _, m, live, err := ts.liveLinkByID(ctx, pend.LinkID) |
| + if err != nil || !live { |
| + return err |
| + } |
| + if !member { |
| + if _, err := ts.mintGrant(ctx, m, p.ID); err != nil { |
| + return err |
| + } |
| + } |
| + dest = "/m/" + m.Sqid + "/join" |
| + return nil |
| + }) |
| + return dest, err |
| +} |
diff --git a/internal/meettest/join_test.go b/internal/meettest/join_test.go| new file mode 100644 |
| index 0000000..474d9a7 |
| --- /dev/null |
| +++ b/internal/meettest/join_test.go |
| @@ -0,0 +1,140 @@ |
| +package meettest |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func memberWithMeeting(t *testing.T) (*harness, string, string) { |
| + t.Helper() |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + sqid, tok := createMeeting(t, c, "Board meeting", "") |
| + return x, sqid, tok |
| +} |
| + |
| +func TestUnknownRevokedAndEndedLinksAreOne404(t *testing.T) { |
| + x, _, _ := memberWithMeeting(t) |
| + rec := x.browser(t).get("/j/" + strings.Repeat("A", 43)) |
| + if rec.Code != http.StatusNotFound { |
| + t.Fatalf("unknown: %d", rec.Code) |
| + } |
| +} |
| + |
| +func TestSignedOutOutsiderSignsInThroughTheLinkInTheSameBrowser(t *testing.T) { |
| + x, sqid, tok := memberWithMeeting(t) |
| + g := x.browser(t) |
| + rec := g.get("/j/" + tok) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin" { |
| + t.Fatalf("follow signed out: %d %s", rec.Code, rec.Header().Get("Location")) |
| + } |
| + if _, ok := g.cookies["meet_join"]; !ok { |
| + t.Fatal("no pending-join cookie") |
| + } |
| + if dest := g.signIn("guest@example.com"); dest != "/m/"+sqid+"/join" { |
| + t.Fatalf("outsider landed on %s", dest) |
| + } |
| + if rec := g.get("/m/" + sqid + "/join"); rec.Code != http.StatusOK { |
| + t.Fatalf("holding page as guest: %d", rec.Code) |
| + } |
| + if rec := g.get("/"); rec.Code != http.StatusNotFound { |
| + t.Fatal("guest can see the meeting list") |
| + } |
| +} |
| + |
| +func TestEmailedLinkWorksInAnotherBrowser(t *testing.T) { |
| + x, sqid, tok := memberWithMeeting(t) |
| + phone := x.browser(t) |
| + phone.get("/j/" + tok) |
| + rec := phone.post("/signin", url.Values{"address": {"guest@example.com"}}) |
| + if rec.Header().Get("Location") != "/signin?sent=1" { |
| + t.Fatal("outsider with cookie refused") |
| + } |
| + _, link := x.mail.last() |
| + laptop := x.browser(t) // no cookie here |
| + if dest := laptop.follow(link); dest != "/m/"+sqid+"/join" { |
| + t.Fatalf("other-browser click landed on %s", dest) |
| + } |
| +} |
| + |
| +func TestLinkReplacedBetweenMailAndClickAdmitsNobody(t *testing.T) { |
| + x, sqid, tok := memberWithMeeting(t) |
| + g := x.browser(t) |
| + g.get("/j/" + tok) |
| + g.post("/signin", url.Values{"address": {"guest@example.com"}}) |
| + _, link := x.mail.last() |
| + member := x.browser(t) |
| + member.signIn("paul@example.com") |
| + member.post("/meetings/"+sqid+"/links", nil) |
| + u, _ := url.Parse(link) |
| + rec := g.get(u.RequestURI()) |
| + if rec.Code != http.StatusForbidden { |
| + t.Fatalf("outsider's stale click: %d, want 403 from Authorize", rec.Code) |
| + } |
| + if rec := g.get("/m/" + sqid + "/join"); rec.Code == http.StatusOK { |
| + t.Fatal("a grant was minted through a replaced link") |
| + } |
| +} |
| + |
| +func TestSignedOutMemberFollowsALinkAndLandsOnItsHoldingPage(t *testing.T) { |
| + x, sqid, tok := memberWithMeeting(t) |
| + m := x.browser(t) |
| + m.get("/j/" + tok) |
| + if dest := m.signIn("paul@example.com"); dest != "/m/"+sqid+"/join" { |
| + t.Fatalf("member landed on %s", dest) |
| + } |
| +} |
| + |
| +func TestOutsiderWithoutCookieStillGetsNoMail(t *testing.T) { |
| + x, _, _ := memberWithMeeting(t) |
| + before := x.mail.count() |
| + x.browser(t).post("/signin", url.Values{"address": {"guest@example.com"}}) |
| + if x.mail.count() != before { |
| + t.Fatal("mail sent to an outsider with no link") |
| + } |
| +} |
| + |
| +func TestOutsiderBudgetsStopMailAtFivePerLinkAndTenOverall(t *testing.T) { |
| + x, _, tok := memberWithMeeting(t) |
| + before := x.mail.count() |
| + for i := 0; i < 7; i++ { |
| + g := x.browser(t) |
| + g.get("/j/" + tok) |
| + g.post("/signin", url.Values{"address": {"g" + string(rune('a'+i)) + "@example.com"}}) |
| + } |
| + if got := x.mail.count() - before; got != 5 { |
| + t.Fatalf("mails through one link: %d, want 5", got) |
| + } |
| + x.advance(16 * time.Minute) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + var toks []string |
| + for i := 0; i < 3; i++ { |
| + _, t2 := createMeeting(t, c, "m", "") |
| + toks = append(toks, t2) |
| + } |
| + before = x.mail.count() |
| + for i := 0; i < 12; i++ { |
| + g := x.browser(t) |
| + g.get("/j/" + toks[i%3]) |
| + g.post("/signin", url.Values{"address": {"h" + string(rune('a'+i)) + "@example.com"}}) |
| + } |
| + if got := x.mail.count() - before; got != 10 { |
| + t.Fatalf("mails across links: %d, want 10", got) |
| + } |
| +} |
| + |
| +func TestExpiredGrantIsRefusedEntry(t *testing.T) { |
| + x, sqid, tok := memberWithMeeting(t) |
| + g := x.browser(t) |
| + g.get("/j/" + tok) |
| + g.signIn("guest@example.com") |
| + x.advance(2*time.Hour + time.Minute) |
| + if rec := g.get("/m/" + sqid + "/join"); rec.Code != http.StatusNotFound { |
| + t.Fatalf("expired guest at the holding page: %d, want 404", rec.Code) |
| + } |
| +} |