package web
import (
"net/http"
"net/url"
"slices"
"strings"
"testing"
)
// Several accounts in one browser, the way GitHub does it: signing in
// while signed in keeps the first session in a stash; the menu offers
// a switch; switching swaps which session is active; signing out wakes
// the most recently stashed one and keeps the rest.
func TestAccountSwitcherHoldsSeveralAndSwaps(t *testing.T) {
srv, accts, sess := betaForge(t)
_ = accts.AddAccount("eve", "longenough1", false)
_ = accts.AddAccount("bob", "longenough1", false)
cookies := func(res *http.Response) (session, others string, othersCleared bool) {
for _, c := range res.Cookies() {
switch c.Name {
case sessionCookie:
session = c.Value
case othersCookie:
others, othersCleared = c.Value, c.MaxAge < 0
}
}
return
}
post := func(path string, form url.Values, jar ...*http.Cookie) *http.Response {
req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range jar {
req.AddCookie(c)
}
res, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
return res
}
// First sign-in: one cookie, no stash.
res := post("/login", url.Values{"user": {"root"}, "password": {"root-pw"}})
s1, o1, _ := cookies(res)
if s1 != "tok-root" || o1 != "" {
t.Fatalf("first sign-in: session %q others %q", s1, o1)
}
// Second account while signed in: the first is stashed, not lost.
res = post("/login", url.Values{"user": {"eve"}, "password": {"longenough1"}}, &http.Cookie{Name: sessionCookie, Value: s1})
s2, o2, _ := cookies(res)
if s2 != "tok-eve" || o2 != "tok-root" {
t.Fatalf("second sign-in: session %q others %q", s2, o2)
}
// The menu offers the switch, by name, and room for more.
req, _ := http.NewRequest("GET", srv.URL+"/", nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: s2})
req.AddCookie(&http.Cookie{Name: othersCookie, Value: o2})
pres, _ := http.DefaultClient.Do(req)
page := body(pres)
if !strings.Contains(page, `action="/switch"`) || !strings.Contains(page, `name="to" value="root"`) {
t.Errorf("the menu offers no switch to root")
}
if !strings.Contains(page, `href="/login?add=1"`) {
t.Errorf("a stash with room does not offer Add account")
}
// Switching swaps active and stashed; a token never crosses the wire.
res = post("/switch", url.Values{"to": {"root"}, "back": {"/r/alpha"}}, &http.Cookie{Name: sessionCookie, Value: s2}, &http.Cookie{Name: othersCookie, Value: o2})
s3, o3, _ := cookies(res)
if s3 != "tok-root" || o3 != "tok-eve" || res.Header.Get("Location") != "/r/alpha" {
t.Fatalf("switch: session %q others %q -> %s", s3, o3, res.Header.Get("Location"))
}
// A third account is KEPT, not traded for the oldest. Two was the
// beta's number and one too few for the ordinary case: your own
// account, an agent account, and somebody you are standing in for.
res = post("/login", url.Values{"user": {"bob"}, "password": {"longenough1"}}, &http.Cookie{Name: sessionCookie, Value: s3}, &http.Cookie{Name: othersCookie, Value: o3})
s4, o4, _ := cookies(res)
if s4 != "tok-bob" || o4 != "tok-root,tok-eve" {
t.Fatalf("third sign-in: session %q others %q", s4, o4)
}
if slices.Contains(sess.out, "tok-eve") {
t.Errorf("a third account ended somebody else's session: %v", sess.out)
}
// And the sign-in that displaced nobody says nothing about it.
if loc := res.Header.Get("Location"); strings.Contains(loc, "signedout") {
t.Errorf("a sign-in with room announced an eviction: %s", loc)
}
// Signing out ends the active session and wakes the most recently
// stashed one. The REST of the stash survives: with more than two
// accounts, signing out of one is not signing out of the others.
res = post("/logout", nil, &http.Cookie{Name: sessionCookie, Value: s4}, &http.Cookie{Name: othersCookie, Value: o4})
s5, o5, cleared := cookies(res)
if s5 != "tok-root" {
t.Fatalf("sign out: session %q", s5)
}
if cleared || o5 != "tok-eve" {
t.Fatalf("sign out dropped the rest of the stash: others %q cleared %v", o5, cleared)
}
if !slices.Contains(sess.out, "tok-bob") {
t.Errorf("sign out did not end the active session: %v", sess.out)
}
// Signing out with nothing stashed clears the browser as before.
res = post("/logout", nil, &http.Cookie{Name: sessionCookie, Value: s5})
if s6, _, _ := cookies(res); s6 != "" {
t.Fatalf("final sign out left a session %q", s6)
}
// The add-account door tells the reader the first account stays.
req, _ = http.NewRequest("GET", srv.URL+"/login?add=1", nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "tok-root"})
pres, _ = http.DefaultClient.Do(req)
if !strings.Contains(body(pres), `data-hook="login-add"`) {
t.Errorf("the add-account login page does not say the first account stays")
}
}
// Going past the limit signs the least recently used account out — and
// SAYS so. It used to happen silently, which is the worst way for a
// limit to be discovered: you find out next time you try to switch.
func TestOverfillingTheStashNamesWhoWasSignedOut(t *testing.T) {
srv, accts, _ := betaForge(t)
names := []string{"eve", "bob", "cal", "dot", "fay", "gus"}
for _, n := range names {
_ = accts.AddAccount(n, "longenough1", false)
}
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
session, others := "", ""
var lastLoc string
signIn := func(user, pw string) {
form := url.Values{"user": {user}, "password": {pw}}
req, _ := http.NewRequest("POST", srv.URL+"/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if session != "" {
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: session})
}
if others != "" {
req.AddCookie(&http.Cookie{Name: othersCookie, Value: others})
}
res, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
lastLoc = res.Header.Get("Location")
for _, c := range res.Cookies() {
switch c.Name {
case sessionCookie:
session = c.Value
case othersCookie:
if c.MaxAge < 0 {
others = ""
} else {
others = c.Value
}
}
}
}
// Fill it exactly: root plus maxAccounts-1 others, nobody displaced.
signIn("root", "root-pw")
for _, n := range names[:maxAccounts-1] {
signIn(n, "longenough1")
}
if strings.Contains(lastLoc, "signedout") {
t.Fatalf("filling the stash exactly displaced somebody: %s", lastLoc)
}
if got := 1 + len(strings.Split(others, ",")); got != maxAccounts {
t.Fatalf("holding %d accounts, want %d (others %q)", got, maxAccounts, others)
}
// One more: the least recently used goes, and the redirect names it.
signIn(names[maxAccounts-1], "longenough1")
if !strings.Contains(lastLoc, "signedout=root") {
t.Errorf("an eviction was not announced: %s", lastLoc)
}
// Still exactly at the limit rather than over it.
if got := 1 + len(strings.Split(others, ",")); got != maxAccounts {
t.Errorf("holding %d accounts after an eviction, want %d", got, maxAccounts)
}
// And the page the reader lands on says it in words.
req, _ := http.NewRequest("GET", srv.URL+"/?signedout=root", nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: session})
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
page := body(res)
if !strings.Contains(page, "root was signed out") {
t.Errorf("the page does not name who was signed out")
}
}