josie / simplegit

package web

import (
	"database/sql"
	"errors"
	"io"
	"net/http"
	"net/http/cookiejar"
	"net/http/httptest"
	"net/url"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"golang.org/x/crypto/bcrypt"

	"git.josie-c.com/josie/simplegit/internal/config"
	"git.josie-c.com/josie/simplegit/internal/db"
)

func newTestServer(t *testing.T) (*httptest.Server, *sql.DB, string) {
	t.Helper()
	database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
	if err != nil {
		t.Fatalf("db.Open: %v", err)
	}
	hash, err := bcrypt.GenerateFromPassword([]byte("hunter2"), bcrypt.MinCost)
	if err != nil {
		t.Fatalf("hash password: %v", err)
	}
	if _, err := db.CreateUser(database, "josie", string(hash)); err != nil {
		t.Fatalf("create user: %v", err)
	}
	cfg := config.Default()
	cfg.DataDir = t.TempDir()
	server, err := New(database, cfg)
	if err != nil {
		t.Fatalf("web.New: %v", err)
	}
	httpServer := httptest.NewServer(server.Handler())
	t.Cleanup(func() {
		httpServer.Close()
		database.Close()
	})
	return httpServer, database, cfg.DataDir
}

func newLoggedInClient(t *testing.T, httpServer *httptest.Server) *http.Client {
	t.Helper()
	client := &http.Client{Transport: httpServer.Client().Transport}
	client.Jar, _ = cookiejar.New(nil)
	resp, err := client.PostForm(httpServer.URL+"/login",
		url.Values{"username": {"josie"}, "password": {"hunter2"}})
	if err != nil {
		t.Fatalf("POST /login: %v", err)
	}
	body := readAll(t, resp)
	if !strings.Contains(body, `href="/josie"`) {
		t.Fatalf("login did not land on the signed-in home: %q", body)
	}
	return client
}

func sessionToken(t *testing.T, client *http.Client, server *httptest.Server) string {
	t.Helper()
	u, err := url.Parse(server.URL)
	if err != nil {
		t.Fatalf("parse server URL: %v", err)
	}
	for _, cookie := range client.Jar.Cookies(u) {
		if cookie.Name == sessionCookieName {
			return cookie.Value
		}
	}
	return ""
}

func readAll(t *testing.T, resp *http.Response) string {
	t.Helper()
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		t.Fatalf("read body: %v", err)
	}
	return string(body)
}

func TestLoginFormRenders(t *testing.T) {
	httpServer, _, _ := newTestServer(t)
	resp, err := httpServer.Client().Get(httpServer.URL + "/login")
	if err != nil {
		t.Fatalf("GET /login: %v", err)
	}
	body := readAll(t, resp)
	if resp.StatusCode != http.StatusOK {
		t.Errorf("status = %d, want 200", resp.StatusCode)
	}
	if !strings.Contains(body, `name="password"`) {
		t.Error("form has no password input")
	}
}

func TestAnonymousHomeShowsSignIn(t *testing.T) {
	httpServer, _, _ := newTestServer(t)
	resp, err := httpServer.Client().Get(httpServer.URL + "/")
	if err != nil {
		t.Fatalf("GET /: %v", err)
	}
	body := readAll(t, resp)
	if strings.Contains(body, `href="/josie"`) {
		t.Error("anonymous home shows a signed-in nav")
	}
	if !strings.Contains(body, `href="/login"`) {
		t.Error("anonymous home has no sign-in link")
	}
}

func TestLoginRejectsBadPassword(t *testing.T) {
	httpServer, _, _ := newTestServer(t)
	resp, err := httpServer.Client().PostForm(httpServer.URL+"/login",
		url.Values{"username": {"josie"}, "password": {"wrong"}})
	if err != nil {
		t.Fatalf("POST /login: %v", err)
	}
	body := readAll(t, resp)
	if resp.StatusCode != http.StatusUnauthorized {
		t.Errorf("status = %d, want 401", resp.StatusCode)
	}
	if !strings.Contains(body, "invalid username or password") {
		t.Errorf("body lacks error message: %q", body)
	}
	for _, cookie := range resp.Cookies() {
		if cookie.Name == sessionCookieName {
			t.Error("session cookie set on failed login")
		}
	}
}

func TestLoginCreatesSessionCookie(t *testing.T) {
	httpServer, database, _ := newTestServer(t)
	client := httpServer.Client()
	client.Jar, _ = cookiejar.New(nil)
	resp, err := client.PostForm(httpServer.URL+"/login",
		url.Values{"username": {"josie"}, "password": {"hunter2"}})
	if err != nil {
		t.Fatalf("POST /login: %v", err)
	}
	body := readAll(t, resp)
	if !strings.Contains(body, `href="/josie"`) {
		t.Errorf("home lacks signed-in banner: %q", body)
	}

	token := sessionToken(t, client, httpServer)
	if token == "" {
		t.Fatal("no session cookie in jar")
	}
	session, err := db.GetSession(database, token)
	if err != nil {
		t.Fatalf("GetSession: %v", err)
	}
	if session.ExpiresAt <= time.Now().Unix() {
		t.Errorf("expires_at = %d, want in the future", session.ExpiresAt)
	}

	resp, err = client.Get(httpServer.URL + "/")
	if err != nil {
		t.Fatalf("GET / with cookie: %v", err)
	}
	body = readAll(t, resp)
	if !strings.Contains(body, `href="/josie"`) {
		t.Errorf("cookie-authenticated home lacks banner: %q", body)
	}
}

func TestLogoutClearsSession(t *testing.T) {
	httpServer, database, _ := newTestServer(t)
	client := newLoggedInClient(t, httpServer)
	token := sessionToken(t, client, httpServer)

	resp, err := client.Post(httpServer.URL+"/logout", "", nil)
	if err != nil {
		t.Fatalf("POST /logout: %v", err)
	}
	body := readAll(t, resp)
	if !strings.Contains(body, "sign in") {
		t.Errorf("logout did not land on the sign-in page: %q", body)
	}
	if _, err := db.GetSession(database, token); !errors.Is(err, db.ErrNotFound) {
		t.Errorf("GetSession after logout err = %v, want ErrNotFound", err)
	}
	if sessionToken(t, client, httpServer) != "" {
		t.Error("session cookie still in jar after logout")
	}
}

// Every response carries the site-wide hardening headers.
func TestSecurityHeaders(t *testing.T) {
	httpServer, _, _ := newTestServer(t)
	resp, err := httpServer.Client().Get(httpServer.URL + "/login")
	if err != nil {
		t.Fatalf("GET /login: %v", err)
	}
	readAll(t, resp)
	if got := resp.Header.Get("X-Frame-Options"); got != "DENY" {
		t.Errorf("X-Frame-Options = %q, want DENY", got)
	}
	if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
		t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
	}
}

func TestStaticStylesheet(t *testing.T) {
	httpServer, _, _ := newTestServer(t)
	resp, err := httpServer.Client().Get(httpServer.URL + "/static/style.css")
	if err != nil {
		t.Fatalf("GET /static/style.css: %v", err)
	}
	body := readAll(t, resp)
	if resp.StatusCode != http.StatusOK {
		t.Errorf("status = %d, want 200", resp.StatusCode)
	}
	if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/css") {
		t.Errorf("Content-Type = %q, want text/css", ct)
	}
	if !strings.Contains(body, "--bgcolor: #16172b") {
		t.Error("stylesheet missing design token")
	}
}

func TestChromaStylesheet(t *testing.T) {
	httpServer, _, _ := newTestServer(t)
	resp, err := httpServer.Client().Get(httpServer.URL + "/static/chroma.css")
	if err != nil {
		t.Fatalf("GET /static/chroma.css: %v", err)
	}
	body := readAll(t, resp)
	if resp.StatusCode != http.StatusOK {
		t.Errorf("status = %d, want 200", resp.StatusCode)
	}
	if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/css") {
		t.Errorf("Content-Type = %q, want text/css", ct)
	}
	if !strings.Contains(body, ".chroma") || !strings.Contains(body, "#a5aef0") {
		t.Error("chroma stylesheet missing rules or palette")
	}
}

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

	resp, err := (&http.Client{}).Get(httpServer.URL + "/")
	if err != nil {
		t.Fatalf("anonymous GET /: %v", err)
	}
	body := readAll(t, resp)
	if !strings.Contains(body, "/josie/pub") {
		t.Error("public repo missing from anonymous home")
	}
	if strings.Contains(body, "/josie/sec") {
		t.Error("private repo leaked to anonymous home")
	}

	resp, err = loggedIn.Get(httpServer.URL + "/")
	if err != nil {
		t.Fatalf("signed-in GET /: %v", err)
	}
	body = readAll(t, resp)
	if !strings.Contains(body, "/josie/pub") || !strings.Contains(body, "/josie/sec") {
		t.Error("signed-in home missing repos")
	}
}

func TestLoginRateLimited(t *testing.T) {
	httpServer, _, _ := newTestServer(t)
	client := httpServer.Client()
	for i := 0; i < loginAttempts; i++ {
		resp, err := client.PostForm(httpServer.URL+"/login",
			url.Values{"username": {"josie"}, "password": {"wrong"}})
		if err != nil {
			t.Fatalf("attempt %d: %v", i+1, err)
		}
		readAll(t, resp)
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("attempt %d = %d, want 401", i+1, resp.StatusCode)
		}
	}
	resp, err := client.PostForm(httpServer.URL+"/login",
		url.Values{"username": {"josie"}, "password": {"wrong"}})
	if err != nil {
		t.Fatalf("limited attempt: %v", err)
	}
	readAll(t, resp)
	if resp.StatusCode != http.StatusTooManyRequests {
		t.Errorf("attempt %d = %d, want 429", loginAttempts+1, resp.StatusCode)
	}
}

// The limiter consumes failures only, so a spray of wrong passwords can
// never lock the owner out: the correct password still gets in.
func TestLoginSucceedsAfterFailureSpray(t *testing.T) {
	httpServer, _, _ := newTestServer(t)
	client := httpServer.Client()
	for i := 0; i < loginAttempts; i++ {
		resp, err := client.PostForm(httpServer.URL+"/login",
			url.Values{"username": {"josie"}, "password": {"wrong"}})
		if err != nil {
			t.Fatalf("attempt %d: %v", i+1, err)
		}
		readAll(t, resp)
		if resp.StatusCode != http.StatusUnauthorized {
			t.Fatalf("attempt %d = %d, want 401", i+1, resp.StatusCode)
		}
	}
	for i := 0; i < 3; i++ {
		resp, err := client.PostForm(httpServer.URL+"/login",
			url.Values{"username": {"josie"}, "password": {"wrong"}})
		if err != nil {
			t.Fatalf("refused attempt %d: %v", i+1, err)
		}
		readAll(t, resp)
		if resp.StatusCode != http.StatusTooManyRequests {
			t.Fatalf("refused attempt %d = %d, want 429", i+1, resp.StatusCode)
		}
	}
	logged, err := client.PostForm(httpServer.URL+"/login",
		url.Values{"username": {"josie"}, "password": {"hunter2"}})
	if err != nil {
		t.Fatalf("correct login after spray: %v", err)
	}
	readAll(t, logged)
	if logged.StatusCode != http.StatusOK {
		t.Errorf("correct login after the failure spray = %d, want 200", logged.StatusCode)
	}
}