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