Commit c7a2a1a6ab

c7a2a1a6ab23caa4dfed33c245b2da26fd37cfa9

parent: cfc0bc7785

Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-05 04:04 UTC

e2e: a link minted before suspension opens nothing

Covers the other ordering: mint while the account is in good standing,
disable, then follow. Asserts the refusal is byte-identical to the one an
unknown token gets, and that no session results.

Ref #155

Layout: unified · split

e2e/emaillogin_test.go +46 −6
@@ -31,12 +31,7 @@ func TestEmailLogin(t *testing.T) {
31 t.Fatalf("no confirmation in body: %s", body) 31 t.Fatalf("no confirmation in body: %s", body)
32 } 32 }
33 33
34 msg := smtp.waitFor(t, "dana@example.test", "/login?token=") 34 link := loginLinkIn(smtp.waitFor(t, "dana@example.test", "/login?token="))
35 i := strings.Index(msg, "/login?token=")
36 link := msg[i:]
37 if j := strings.IndexAny(link, " \r\n"); j >= 0 {
38 link = link[:j]
39 }
40 35
41 if status, _ := browserGet(t, browser, inst.base()+link); status != 200 { 36 if status, _ := browserGet(t, browser, inst.base()+link); status != 200 {
42 t.Fatalf("following the link: %d", status) 37 t.Fatalf("following the link: %d", status)
@@ -201,3 +196,48 @@ func TestEmailLoginRefusesDisabledAccount(t *testing.T) {
201 t.Error("a disabled account got a browser session") 196 t.Error("a disabled account got a browser session")
202 } 197 }
203} 198}
199
200// The other ordering: the link is minted while the account is in good
201// standing and followed after it is suspended. Suspension drops the pending
202// login tokens, and login() refuses a disabled account after consuming one,
203// so neither the window nor a token that somehow survives it opens a session.
204func TestEmailLoginRefusesLinkMintedBeforeSuspension(t *testing.T) {
205 smtp := startFakeSMTP(t)
206 inst := startInstanceWith(t, fmt.Sprintf(
207 "[web]\nmode = \"accounts\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
208 smtp.addr))
209 inst.admin(t, "admin", "user", "create", "dana",
210 "--email", "dana@example.test", "--verified")
211
212 browser := newBrowser(t)
213 if status, _ := browserPost(t, browser, inst.base()+"/login",
214 url.Values{"identifier": {"dana@example.test"}}); status != 200 {
215 t.Fatalf("POST /login: %d", status)
216 }
217 link := loginLinkIn(smtp.waitFor(t, "dana@example.test", "/login?token="))
218
219 inst.admin(t, "admin", "user", "disable", "dana")
220
221 _, body := browserGet(t, browser, inst.base()+link)
222 if !strings.Contains(body, "invalid, expired, or already used") {
223 t.Errorf("no refusal on the login page: %s", body)
224 }
225 // A refusal that reads differently from an ordinary bad token says the
226 // account exists and is suspended, which is the leak the endpoint is
227 // built to avoid.
228 if _, bogus := browserGet(t, browser, inst.base()+"/login?token=notatoken"); body != bogus {
229 t.Error("a suspended account's refusal differs from a bad token's")
230 }
231 if _, body := browserGet(t, browser, inst.base()+"/settings"); strings.Contains(body, "dana@example.test") {
232 t.Error("a link minted before suspension still opened a session")
233 }
234}
235
236// loginLinkIn pulls the /login?token=... path out of a login mail.
237func loginLinkIn(msg string) string {
238 link := msg[strings.Index(msg, "/login?token="):]
239 if j := strings.IndexAny(link, " \r\n"); j >= 0 {
240 link = link[:j]
241 }
242 return link
243}