Skip to content
quarry Artifact [43e53a56e1]
Login

Artifact [43e53a56e1]

Artifact 43e53a56e1a38b6a2c5f7b5685d893caa3d23a808d9b75aae29873883a1eacbb:


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")
	}
}

// The image editor's hooks and the script that reads them are on BOTH
// settings pages. They were not: the team's page never loaded
// settings.js, so the preview and paste would have worked on a
// repository and silently not on a team — the worst kind of
// difference, because nothing says which page you are on.
func TestBothSettingsPagesCarryTheImageEditor(t *testing.T) {
	td := teamDetail{Team: teamRow{Name: "core", Title: "Core"}, CanManage: true, Sect: "settings"}
	// The HANDLER's own page data, not a second copy of it: a test
	// that builds its own pageData proves the template works when
	// handed the right fields, which is not the question when the bug
	// is a handler that hands it the wrong ones.
	// Storage bound, so the upload control is drawn at all: with none
	// the section correctly shows an explanation instead, which is a
	// different test.
	old := avatarTag
	t.Cleanup(func() { avatarTag = old })
	avatarTag = func(kind, name string) (string, bool) { return "", false }
	data := teamSettingsData(&td, false, nil)
	data.SettingsURL = "/settings.js"
	var team strings.Builder
	if err := teamSettingsPage.Execute(&team, data); err != nil {
		t.Fatal(err)
	}
	for _, want := range []string{
		`data-hook="avatar-edit"`, `data-hook="avatar-current"`,
		`data-hook="avatar-undo"`, `type="file"`,
		// and the script that gives those hooks meaning
		`src="/settings.js"`,
	} {
		if !strings.Contains(team.String(), want) {
			t.Errorf("FAIL: the team's settings page is missing %q", want)
		}
	}
	// The undo ships hidden: an undo for a preview that never happened
	// is a control that does nothing.
	if !strings.Contains(team.String(), `data-hook="avatar-undo" hidden`) {
		t.Errorf("FAIL: the undo is not hidden in the markup")
	}
}