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