package callstest import ( "net/http" "net/url" "strconv" "strings" "testing" "time" ) // memberWithMeeting's harness carries a second member, paul@rslw.com, // unused by most callers, for the signal tests that need two members in // one room (newMemberBrowser). func memberWithMeeting(t *testing.T) (*harness, string, string) { t.Helper() x := newHarness(t, "paul@example.com", "paul@rslw.com") c := x.browser(t) c.signIn("paul@example.com") sqid, tok := createMeeting(t, c, "Board meeting", "") return x, sqid, tok } // Spec §3.2's one 404: unknown, revoked and ended are not merely all // 404, they are the same 404 — the same status and the same bytes, so // the response tells an outsider nothing about which it was. func TestUnknownRevokedAndEndedLinksAreOne404(t *testing.T) { x, replaced, revoked := memberWithMeeting(t) c := x.browser(t) c.signIn("paul@example.com") if rec := c.post("/meetings/"+replaced+"/links", nil); rec.Code != http.StatusOK { t.Fatalf("replace link: %d", rec.Code) } ended, endedTok := createMeeting(t, c, "over", "") if rec := c.post("/meetings/"+ended+"/end", nil); rec.Code != http.StatusSeeOther { t.Fatalf("end meeting: %d", rec.Code) } bodies := map[string]string{} for _, tc := range []struct{ name, token string }{ {"unknown", strings.Repeat("A", 43)}, {"revoked", revoked}, {"ended", endedTok}, } { rec := x.browser(t).get("/j/" + tc.token) if rec.Code != http.StatusNotFound { t.Fatalf("%s: %d, want 404", tc.name, rec.Code) } bodies[tc.name] = rec.Body.String() } if bodies["revoked"] != bodies["unknown"] || bodies["ended"] != bodies["unknown"] { t.Fatalf("the three 404s differ:\nunknown: %q\nrevoked: %q\nended: %q", bodies["unknown"], bodies["revoked"], bodies["ended"]) } } func TestSignedOutOutsiderSignsInThroughTheLinkInTheSameBrowser(t *testing.T) { x, sqid, tok := memberWithMeeting(t) member := x.browser(t) member.signIn("paul@example.com") member.post("/meetings/"+sqid+"/settings", url.Values{"require_signin": {"on"}}) 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["calls_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.StatusSeeOther || rec.Header().Get("Location") != "/welcome" { 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) // Authorize refuses it, and the refusal is the door rendered in a // 403 by auth's Refused hook, not rastrillo's bare text. rec := g.redeem(link) if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "can’t sign in to Calls") { t.Fatalf("outsider's stale click: %d, want Authorize's refusal as the door in a 403", 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 TestMemberAddressExhaustsTheSharedLimiterWithTheSameAnswer(t *testing.T) { x := newHarness(t, "paul@example.com") for i := 0; i < 5; i++ { g := x.browser(t) rec := g.post("/signin", url.Values{"address": {"paul@example.com"}}) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" { t.Fatalf("attempt %d: %d %s, want 303 /signin?sent=1", i, rec.Code, rec.Header().Get("Location")) } } g := x.browser(t) rec := g.post("/signin", url.Values{"address": {"paul@example.com"}}) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?err=rate" { t.Fatalf("sixth attempt: %d %s, want 303 /signin?err=rate", rec.Code, rec.Header().Get("Location")) } if got := x.mail.count(); got != 5 { t.Fatalf("mail count: %d, want 5", got) } if body := g.get("/signin?err=rate").Body.String(); !strings.Contains(body, "Too many attempts. Wait a few minutes, then try again.") { t.Fatal("?err=rate does not render rastrillo's rate-limit message") } } // Review C1-8: the twenty-per-client allowance, not the five-per-address // one. rastrillo's limiter is keyed on RemoteAddr, which // httptest.NewRequest fixes at 192.0.2.1:1234, so every harness client // shares one budget. Twenty-one different member addresses therefore // exhaust it even though no address is near its own five. // // This is spec §3.2's filed residue, not a quirk of the harness. Behind // the platform edge, RemoteAddr is the proxy, so every visitor of every // team shares this one budget: twenty sign-in attempts per fifteen // minutes for the whole world, exhaustible on purpose by anyone, and // nothing in front of Begin can tell one client from another. The fix is // a trusted client address from the edge or a per-app limiter keyed on // something better; the question is filed on the platform. func TestSharedClientAllowanceIsExhaustedByOneClient(t *testing.T) { const clientBudget = 20 // signin.Flow.IPBudget's default members := make([]string, 0, clientBudget+1) for i := 0; i <= clientBudget; i++ { members = append(members, "member"+strconv.Itoa(i)+"@example.com") } x := newHarness(t, members...) for i := 0; i < clientBudget; i++ { rec := x.browser(t).post("/signin", url.Values{"address": {members[i]}}) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" { t.Fatalf("attempt %d: %d %s, want 303 /signin?sent=1", i+1, rec.Code, rec.Header().Get("Location")) } } rec := x.browser(t).post("/signin", url.Values{"address": {members[clientBudget]}}) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?err=rate" { t.Fatalf("attempt %d: %d %s, want 303 /signin?err=rate", clientBudget+1, rec.Code, rec.Header().Get("Location")) } if got := x.mail.count(); got != clientBudget { t.Fatalf("mails sent: %d, want %d", got, clientBudget) } } // Spec §3.2: a member who followed a link while signed out reaches the // holding screen in whichever browser the emailed link is opened — by // the same pending row an outsider uses. The existing member test opens // the link in the browser that asked for it (review C1-8). func TestSignedOutMemberCompletesInAnotherBrowser(t *testing.T) { x, sqid, tok := memberWithMeeting(t) desktop := x.browser(t) desktop.get("/j/" + tok) rec := desktop.post("/signin", url.Values{"address": {"paul@example.com"}}) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" { t.Fatalf("member's request: %d %s", rec.Code, rec.Header().Get("Location")) } to, link := x.mail.last() if to != "paul@example.com" || link == "" { t.Fatalf("no magic link mailed to the member (last to %q)", to) } phone := x.browser(t) // no session, no pending-join cookie if dest := phone.follow(link); dest != "/m/"+sqid+"/join" { t.Fatalf("other-browser click landed on %s, want the holding page", dest) } if rec := phone.get("/m/" + sqid + "/join"); rec.Code != http.StatusOK { t.Fatalf("holding page in the second browser: %d", rec.Code) } } // Spec §3.2's two refusals: a signed-in person whose pending row fails // the /continue re-check gets / or /welcome, and neither path mints a // grant. The old test could only observe the redirect; this one asserts // the absence of the grant by asking for the holding page (review C1-8). func TestReplacedLinkFallbackForSignedInPerson(t *testing.T) { t.Run("member lands on the list", func(t *testing.T) { x, sqid, tok := memberWithMeeting(t) owner := x.browser(t) owner.signIn("paul@example.com") // A pending row written after the session exists, from another // browser, so signing in did not already consume it. other := x.browser(t) other.get("/j/" + tok) if rec := other.post("/signin", url.Values{"address": {"paul@example.com"}}); rec.Header().Get("Location") != "/signin?sent=1" { t.Fatalf("member's request: %s", rec.Header().Get("Location")) } if rec := owner.post("/meetings/"+sqid+"/links", nil); rec.Code != http.StatusOK { t.Fatalf("replace link: %d", rec.Code) } rec := owner.get("/continue") if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { t.Fatalf("member after a replaced link: %d %s, want 303 /", rec.Code, rec.Header().Get("Location")) } }) t.Run("non-member lands on welcome with no grant", func(t *testing.T) { x, first, firstTok := memberWithMeeting(t) guest := x.browser(t) guest.get("/j/" + firstTok) guest.signIn("guest@example.com") // signed in, and granted the first meeting owner := x.browser(t) owner.signIn("paul@example.com") second, secondTok := createMeeting(t, owner, "second", "") // The pending row for the second meeting, from a browser with no // session, so the guest's own session is untouched. other := x.browser(t) other.get("/j/" + secondTok) if rec := other.post("/signin", url.Values{"address": {"guest@example.com"}}); rec.Header().Get("Location") != "/signin?sent=1" { t.Fatalf("outsider's request: %s", rec.Header().Get("Location")) } if rec := owner.post("/meetings/"+second+"/links", nil); rec.Code != http.StatusOK { t.Fatalf("replace link: %d", rec.Code) } rec := guest.get("/continue") if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/welcome" { t.Fatalf("non-member after a replaced link: %d %s, want 303 /welcome", rec.Code, rec.Header().Get("Location")) } if rec := guest.get("/m/" + second + "/join"); rec.Code != http.StatusNotFound { t.Fatalf("holding page of the second meeting: %d, want 404 — a grant was minted", rec.Code) } // The grant they legitimately hold is untouched. if rec := guest.get("/m/" + first + "/join"); rec.Code != http.StatusOK { t.Fatalf("holding page of the first meeting: %d, want 200", rec.Code) } }) // Spec §3.2's third continuation case for a non-member: the pending // row is not replaced but simply expired. An outsider whose row // expires before they click is refused at Authorize with a 403 and // never reaches /continue (TestExpiredPendingRowAdmitsNobody), so the // only way to exercise this branch is with a session that already // exists — which is the gate the review found missing (C1-8). t.Run("non-member with an expired row lands on welcome with no grant", func(t *testing.T) { x, first, firstTok := memberWithMeeting(t) guest := x.browser(t) guest.get("/j/" + firstTok) guest.signIn("guest@example.com") owner := x.browser(t) owner.signIn("paul@example.com") second, secondTok := createMeeting(t, owner, "second", "") other := x.browser(t) other.get("/j/" + secondTok) if rec := other.post("/signin", url.Values{"address": {"guest@example.com"}}); rec.Header().Get("Location") != "/signin?sent=1" { t.Fatalf("outsider's request: %s", rec.Header().Get("Location")) } // "?sent=1" alone is also the answer to a refusal (spec §3.2's // same-answer rule) — confirm the pending row this subtest means // to expire was actually written, not just that the request was // accepted-looking (Codex round 3, C3-3). if n := x.pendingJoins(t); n != 1 { t.Fatalf("pending_joins rows after the outsider's second POST /signin: %d, want 1", n) } // The link stays live; only the row runs out (fifteen minutes). x.advance(16 * time.Minute) rec := guest.get("/continue") if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/welcome" { t.Fatalf("non-member with an expired row: %d %s, want 303 /welcome", rec.Code, rec.Header().Get("Location")) } if rec := guest.get("/m/" + second + "/join"); rec.Code != http.StatusNotFound { t.Fatalf("holding page of the second meeting: %d, want 404 — a grant was minted", rec.Code) } if rec := guest.get("/m/" + first + "/join"); rec.Code != http.StatusOK { t.Fatalf("holding page of the first meeting: %d, want 200", rec.Code) } }) } // Review follow-up to C1-7: the point of the sweep is that an unclicked // magic link does not keep a plaintext address in rastrillo's auth_links // for the life of the instance. Asserted on the row itself. // // Eleven minutes is not enough: signin.Flow.LinkTTL defaults to fifteen // (keymaildev/signin@v0.1.1/flow.go:186) and rastrillo sets no override, // so a sweep at +11 must keep the row and one at +16 must take it. Both // halves are asserted, because a sweep that deleted everything would // pass the second on its own. func TestSweepClearsUnclickedMagicLinks(t *testing.T) { x := newHarness(t, "paul@example.com") if rec := x.browser(t).post("/signin", url.Values{"address": {"paul@example.com"}}); rec.Header().Get("Location") != "/signin?sent=1" { t.Fatalf("sign-in request: %s", rec.Header().Get("Location")) } if x.authLinks(t) != 1 { t.Fatalf("auth_links rows after a sign-in request: %d, want 1", x.authLinks(t)) } x.advance(11 * time.Minute) x.sweep() if n := x.authLinks(t); n != 1 { t.Fatalf("auth_links rows after a sweep inside the link's life: %d, want 1", n) } x.advance(5 * time.Minute) x.sweep() if n := x.authLinks(t); n != 0 { t.Fatalf("auth_links rows after the link expired and was swept: %d, want 0", n) } } func TestExpiredPendingRowAdmitsNobody(t *testing.T) { x, _, tok := memberWithMeeting(t) g := x.browser(t) g.get("/j/" + tok) rec := g.post("/signin", url.Values{"address": {"guest@example.com"}}) if rec.Header().Get("Location") != "/signin?sent=1" { t.Fatalf("outsider's request: %d %s", rec.Code, rec.Header().Get("Location")) } before := x.mail.count() _, link := x.mail.last() x.advance(16 * time.Minute) if rec := g.redeem(link); rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "can’t sign in to Calls") { t.Fatalf("expired pending row click: %d, want Authorize's refusal as the door in a 403", rec.Code) } if got := x.mail.count(); got != before { t.Fatalf("mail count changed after the expired click: %d, want %d", got, before) } x2, _, tok2 := memberWithMeeting(t) m := x2.browser(t) m.get("/j/" + tok2) rec = m.post("/signin", url.Values{"address": {"paul@example.com"}}) if rec.Header().Get("Location") != "/signin?sent=1" { t.Fatalf("member's request: %d %s", rec.Code, rec.Header().Get("Location")) } _, link2 := x2.mail.last() x2.advance(16 * time.Minute) if dest := m.follow(link2); dest != "/" { t.Fatalf("signed-out member after an expired pending row landed on %s, want /", dest) } } 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) } } // TestPendingRowExpiryIsByInstantWhateverTheClocksZone: pending_joins' // expires_at is compared in SQL against a UTC parameter (pendingLive's // "expires_at > ?" and the sweep's "expires_at <= ?"), so it agrees with // the instants only while every row is stored in one zone. rastrillo // fb806a2 opens the database with _timezone=UTC, which converts the // store's own clock (time.Now, in the machine's zone) on the way in. // Before that, a machine nine hours ahead of UTC stored a row that read // as nine hours later than it was: never swept and live at Authorize // long after its fifteen minutes. Both directions are checked. func TestPendingRowExpiryIsByInstantWhateverTheClocksZone(t *testing.T) { for _, offset := range []int{9, -9} { t.Run("UTC"+strconv.FormatInt(int64(offset), 10), func(t *testing.T) { x, _, tok := memberWithMeeting(t) x.now = x.now.In(time.FixedZone("", offset*3600)) x.advance(0) g := x.browser(t) g.get("/j/" + tok) if rec := g.post("/signin", url.Values{"address": {"guest@example.com"}}); rec.Header().Get("Location") != "/signin?sent=1" { t.Fatalf("outsider's request: %s", rec.Header().Get("Location")) } if n := x.pendingJoins(t); n != 1 { t.Fatalf("pending_joins rows: %d, want 1", n) } x.advance(14 * time.Minute) x.sweep() if n := x.pendingJoins(t); n != 1 { t.Fatalf("a sweep inside the row's fifteen minutes took it") } x.advance(2 * time.Minute) x.sweep() if n := x.pendingJoins(t); n != 0 { t.Fatalf("a sweep after the row's fifteen minutes left %d", n) } }) } }