b93e14ae88f05c6e01d25ab41d2ecb7456d769b3 / internal/web/web_test.go · 10005 bytes · raw
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)
}
}