josie / simplegit

package web

import (
	"net/http"
	"net/http/cookiejar"
	"net/url"
	"regexp"
	"strings"
	"testing"
)

func newJar(t *testing.T) http.CookieJar {
	t.Helper()
	jar, err := cookiejar.New(nil)
	if err != nil {
		t.Fatalf("cookiejar: %v", err)
	}
	return jar
}

var (
	tokenPattern = regexp.MustCompile(`sg_[A-Za-z0-9_-]+`)
	tokenIDPath  = regexp.MustCompile(`/settings/tokens/(\d+)/revoke`)
)

func TestSettingsRequiresLogin(t *testing.T) {
	httpServer, _, _ := newTestServer(t)
	noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
		return http.ErrUseLastResponse
	}}
	resp, err := noFollow.Get(httpServer.URL + "/settings")
	if err != nil {
		t.Fatalf("anonymous GET /settings: %v", err)
	}
	readAll(t, resp)
	if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/login" {
		t.Errorf("anonymous settings = %d %q, want 303 /login", resp.StatusCode, resp.Header.Get("Location"))
	}
}

func TestChangePassword(t *testing.T) {
	httpServer, _, _ := newTestServer(t)
	josie := newLoggedInClient(t, httpServer)
	other := newLoggedInClient(t, httpServer)
	change := func(current, next, confirm string) string {
		t.Helper()
		resp, err := josie.PostForm(httpServer.URL+"/settings/password", url.Values{
			"current_password": {current}, "new_password": {next}, "confirm_password": {confirm},
		})
		if err != nil {
			t.Fatalf("POST /settings/password: %v", err)
		}
		return readAll(t, resp)
	}

	if body := change("wrong", "newpass", "newpass"); !strings.Contains(body, "current password is incorrect") {
		t.Errorf("wrong current password: body = %q", body)
	}
	if body := change("hunter2", "newpass", "different"); !strings.Contains(body, "do not match") {
		t.Errorf("mismatched confirm: body = %q", body)
	}
	if body := change("hunter2", "newpass", "newpass"); !strings.Contains(body, "password changed") {
		t.Errorf("valid change: body = %q", body)
	}

	// The session that made the change stays signed in.
	if resp, err := josie.Get(httpServer.URL + "/settings"); err != nil {
		t.Fatalf("GET /settings after change: %v", err)
	} else if readAll(t, resp); resp.StatusCode != http.StatusOK {
		t.Errorf("current session after change = %d, want 200", resp.StatusCode)
	}

	// A different session is revoked.
	noFollow := &http.Client{
		Transport: other.Transport,
		Jar:       other.Jar,
		CheckRedirect: func(*http.Request, []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}
	resp, err := noFollow.Get(httpServer.URL + "/settings")
	if err != nil {
		t.Fatalf("other session GET: %v", err)
	}
	readAll(t, resp)
	if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/login" {
		t.Errorf("other session = %d %q, want 303 /login", resp.StatusCode, resp.Header.Get("Location"))
	}

	// Old password no longer works; the new one does.
	fresh := &http.Client{Transport: httpServer.Client().Transport, Jar: newJar(t)}
	resp, err = fresh.PostForm(httpServer.URL+"/login", url.Values{"username": {"josie"}, "password": {"hunter2"}})
	if err != nil {
		t.Fatalf("login old password: %v", err)
	}
	readAll(t, resp)
	if resp.StatusCode != http.StatusUnauthorized {
		t.Errorf("old password login = %d, want 401", resp.StatusCode)
	}
	resp, err = fresh.PostForm(httpServer.URL+"/login", url.Values{"username": {"josie"}, "password": {"newpass"}})
	if err != nil {
		t.Fatalf("login new password: %v", err)
	}
	if body := readAll(t, resp); !strings.Contains(body, `href="/josie"`) {
		t.Errorf("new password login rejected: %q", body)
	}
}

func basicRefs(t *testing.T, httpServerURL, user, secret string) int {
	t.Helper()
	req, err := http.NewRequest("GET", httpServerURL+"/josie/sec.git/info/refs?service=git-upload-pack", nil)
	if err != nil {
		t.Fatalf("new request: %v", err)
	}
	req.SetBasicAuth(user, secret)
	resp, err := (&http.Client{}).Do(req)
	if err != nil {
		t.Fatalf("basic refs: %v", err)
	}
	readAll(t, resp)
	return resp.StatusCode
}

func TestTokenCreateUseRevoke(t *testing.T) {
	httpServer, database, _ := newTestServer(t)
	loggedIn := newLoggedInClient(t, httpServer)
	createRepo(t, loggedIn, httpServer, "sec", "private")

	resp, err := loggedIn.PostForm(httpServer.URL+"/settings/tokens", url.Values{"name": {"laptop"}})
	if err != nil {
		t.Fatalf("create token: %v", err)
	}
	body := readAll(t, resp)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("create token status = %d: %q", resp.StatusCode, body)
	}
	token := tokenPattern.FindString(body)
	if token == "" {
		t.Fatalf("response did not show a new token: %q", body)
	}

	list, err := loggedIn.Get(httpServer.URL + "/settings")
	if err != nil {
		t.Fatalf("GET /settings: %v", err)
	}
	listBody := readAll(t, list)
	if !strings.Contains(listBody, "laptop") || !strings.Contains(listBody, token[:8]) {
		t.Errorf("settings list lacks the token row: %q", listBody)
	}
	if strings.Contains(listBody, token) {
		t.Error("full token secret leaked into the settings list")
	}

	if code := basicRefs(t, httpServer.URL, "josie", token); code != http.StatusOK {
		t.Errorf("token as basic-auth password = %d, want 200", code)
	}
	if code := basicRefs(t, httpServer.URL, "josie", "sg_not-a-real-token"); code != http.StatusUnauthorized {
		t.Errorf("bogus token = %d, want 401", code)
	}

	match := tokenIDPath.FindStringSubmatch(listBody)
	if match == nil {
		t.Fatalf("no revoke link in settings: %q", listBody)
	}

	// A different user must not be able to revoke someone else's token.
	addUser(t, database, "mallory", "pw")
	mallory := loginAs(t, httpServer, "mallory", "pw")
	if resp, err := mallory.PostForm(httpServer.URL+"/settings/tokens/"+match[1]+"/revoke", nil); err == nil {
		readAll(t, resp)
	}
	if code := basicRefs(t, httpServer.URL, "josie", token); code != http.StatusOK {
		t.Errorf("token invalidated by a non-owner = %d, want still 200", code)
	}

	revoke, err := loggedIn.PostForm(httpServer.URL+"/settings/tokens/"+match[1]+"/revoke", nil)
	if err != nil {
		t.Fatalf("revoke token: %v", err)
	}
	readAll(t, revoke)
	if code := basicRefs(t, httpServer.URL, "josie", token); code != http.StatusUnauthorized {
		t.Errorf("revoked token = %d, want 401", code)
	}
}