293 lines
9.0 KiB
Go
293 lines
9.0 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"5803025015/w04/internal/auth"
|
|
"5803025015/w04/internal/models"
|
|
)
|
|
|
|
// ---------- helpers ----------
|
|
|
|
func setupAuthHandler() (*AuthHandler, *fakeUserStore, *fakeSessionStore) {
|
|
us := newFakeUserStore()
|
|
ss := newFakeSessionStore()
|
|
return NewAuthHandler(us, ss), us, ss
|
|
}
|
|
|
|
func doRequest(
|
|
h http.HandlerFunc,
|
|
method, path string,
|
|
body []byte,
|
|
headers map[string]string,
|
|
) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(method, path, bytes.NewReader(body))
|
|
if headers != nil {
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func assertContentTypeJSON(t *testing.T, rec *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
if rec.Code == http.StatusNoContent {
|
|
return
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
|
t.Fatalf("want Content-Type application/json, got %q (status=%d, body=%q)",
|
|
ct, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func decodeMap(t *testing.T, body []byte) map[string]any {
|
|
t.Helper()
|
|
var m map[string]any
|
|
if err := json.Unmarshal(body, &m); err != nil {
|
|
t.Fatalf("body not valid JSON: %v; body=%q", err, string(body))
|
|
}
|
|
return m
|
|
}
|
|
|
|
// ---------- TestRegister ----------
|
|
|
|
func TestRegister(t *testing.T) {
|
|
t.Run("success -> 201 with id and username", func(t *testing.T) {
|
|
h, _, _ := setupAuthHandler()
|
|
rec := doRequest(h.Register, http.MethodPost, "/register",
|
|
[]byte(`{"username":"alice","password":"secret123"}`), nil)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("want 201, got %d (body=%s)", rec.Code, rec.Body.String())
|
|
}
|
|
assertContentTypeJSON(t, rec)
|
|
m := decodeMap(t, rec.Body.Bytes())
|
|
if m["id"] == nil || m["id"] == "" {
|
|
t.Fatalf("missing id in response: %v", m)
|
|
}
|
|
if m["username"] != "alice" {
|
|
t.Fatalf("want username alice, got %v", m["username"])
|
|
}
|
|
})
|
|
|
|
t.Run("duplicate username -> 409", func(t *testing.T) {
|
|
h, _, _ := setupAuthHandler()
|
|
body := []byte(`{"username":"alice","password":"secret123"}`)
|
|
_ = doRequest(h.Register, http.MethodPost, "/register", body, nil)
|
|
rec := doRequest(h.Register, http.MethodPost, "/register", body, nil)
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("want 409, got %d (body=%s)", rec.Code, rec.Body.String())
|
|
}
|
|
assertContentTypeJSON(t, rec)
|
|
})
|
|
|
|
t.Run("empty username -> 400", func(t *testing.T) {
|
|
h, _, _ := setupAuthHandler()
|
|
rec := doRequest(h.Register, http.MethodPost, "/register",
|
|
[]byte(`{"username":"","password":"secret123"}`), nil)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("want 400, got %d", rec.Code)
|
|
}
|
|
assertContentTypeJSON(t, rec)
|
|
})
|
|
|
|
t.Run("empty password -> 400", func(t *testing.T) {
|
|
h, _, _ := setupAuthHandler()
|
|
rec := doRequest(h.Register, http.MethodPost, "/register",
|
|
[]byte(`{"username":"alice","password":""}`), nil)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("want 400, got %d", rec.Code)
|
|
}
|
|
assertContentTypeJSON(t, rec)
|
|
})
|
|
|
|
t.Run("username of 2 chars -> 400", func(t *testing.T) {
|
|
h, _, _ := setupAuthHandler()
|
|
rec := doRequest(h.Register, http.MethodPost, "/register",
|
|
[]byte(`{"username":"ab","password":"secret123"}`), nil)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("want 400, got %d", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("username of 33 chars -> 400", func(t *testing.T) {
|
|
h, _, _ := setupAuthHandler()
|
|
long := strings.Repeat("a", 33)
|
|
rec := doRequest(h.Register, http.MethodPost, "/register",
|
|
[]byte(`{"username":"`+long+`","password":"secret123"}`), nil)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("want 400, got %d", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("malformed JSON -> 400", func(t *testing.T) {
|
|
h, _, _ := setupAuthHandler()
|
|
rec := doRequest(h.Register, http.MethodPost, "/register",
|
|
[]byte(`{"username":"alice"`), nil)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("want 400, got %d", rec.Code)
|
|
}
|
|
assertContentTypeJSON(t, rec)
|
|
})
|
|
|
|
t.Run("success body has no password / password_hash key", func(t *testing.T) {
|
|
h, _, _ := setupAuthHandler()
|
|
rec := doRequest(h.Register, http.MethodPost, "/register",
|
|
[]byte(`{"username":"alice","password":"secret123"}`), nil)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("want 201, got %d", rec.Code)
|
|
}
|
|
raw := rec.Body.String()
|
|
if strings.Contains(raw, "password") {
|
|
t.Fatalf("body should not contain 'password': %s", raw)
|
|
}
|
|
if strings.Contains(raw, "secret123") {
|
|
t.Fatalf("body leaks plaintext password: %s", raw)
|
|
}
|
|
m := decodeMap(t, rec.Body.Bytes())
|
|
if _, ok := m["password"]; ok {
|
|
t.Fatal("response has 'password' key")
|
|
}
|
|
if _, ok := m["password_hash"]; ok {
|
|
t.Fatal("response has 'password_hash' key")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------- TestLogin ----------
|
|
|
|
func TestLogin(t *testing.T) {
|
|
t.Run("success -> 200 with non-empty session_token", func(t *testing.T) {
|
|
h, us, _ := setupAuthHandler()
|
|
hash, _ := auth.HashPassword("secret123")
|
|
_ = us.Create(nil, &models.User{ID: "u1", Username: "alice", PasswordHash: hash})
|
|
|
|
rec := doRequest(h.Login, http.MethodPost, "/login",
|
|
[]byte(`{"username":"alice","password":"secret123"}`), nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("want 200, got %d (body=%s)", rec.Code, rec.Body.String())
|
|
}
|
|
assertContentTypeJSON(t, rec)
|
|
m := decodeMap(t, rec.Body.Bytes())
|
|
tok, _ := m["session_token"].(string)
|
|
if tok == "" {
|
|
t.Fatalf("want non-empty session_token, got %v", m)
|
|
}
|
|
})
|
|
|
|
t.Run("wrong password -> 401", func(t *testing.T) {
|
|
h, us, _ := setupAuthHandler()
|
|
hash, _ := auth.HashPassword("secret123")
|
|
_ = us.Create(nil, &models.User{ID: "u1", Username: "alice", PasswordHash: hash})
|
|
|
|
rec := doRequest(h.Login, http.MethodPost, "/login",
|
|
[]byte(`{"username":"alice","password":"wrong"}`), nil)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("want 401, got %d", rec.Code)
|
|
}
|
|
assertContentTypeJSON(t, rec)
|
|
})
|
|
|
|
t.Run("unknown username -> 401", func(t *testing.T) {
|
|
h, _, _ := setupAuthHandler()
|
|
rec := doRequest(h.Login, http.MethodPost, "/login",
|
|
[]byte(`{"username":"ghost","password":"whatever"}`), nil)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("want 401, got %d", rec.Code)
|
|
}
|
|
assertContentTypeJSON(t, rec)
|
|
})
|
|
|
|
t.Run("wrong password and unknown user bodies are byte-for-byte identical", func(t *testing.T) {
|
|
h, us, _ := setupAuthHandler()
|
|
hash, _ := auth.HashPassword("secret123")
|
|
_ = us.Create(nil, &models.User{ID: "u1", Username: "alice", PasswordHash: hash})
|
|
|
|
rec1 := doRequest(h.Login, http.MethodPost, "/login",
|
|
[]byte(`{"username":"alice","password":"wrong"}`), nil)
|
|
rec2 := doRequest(h.Login, http.MethodPost, "/login",
|
|
[]byte(`{"username":"ghost","password":"whatever"}`), nil)
|
|
|
|
if rec1.Code != rec2.Code {
|
|
t.Fatalf("status codes differ: %d vs %d", rec1.Code, rec2.Code)
|
|
}
|
|
if !bytes.Equal(rec1.Body.Bytes(), rec2.Body.Bytes()) {
|
|
t.Fatalf("bodies differ:\n1=%q\n2=%q", rec1.Body.String(), rec2.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("malformed JSON -> 400", func(t *testing.T) {
|
|
h, _, _ := setupAuthHandler()
|
|
rec := doRequest(h.Login, http.MethodPost, "/login",
|
|
[]byte(`{"username":"alice"`), nil)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("want 400, got %d", rec.Code)
|
|
}
|
|
assertContentTypeJSON(t, rec)
|
|
})
|
|
}
|
|
|
|
// ---------- TestLogout ----------
|
|
|
|
func TestLogout(t *testing.T) {
|
|
t.Run("valid token -> 204 empty body, session removed", func(t *testing.T) {
|
|
h, _, ss := setupAuthHandler()
|
|
_ = ss.Create(nil, &models.Session{Token: "tok-1", UserID: "u1"})
|
|
|
|
rec := doRequest(h.Logout, http.MethodPost, "/logout", nil,
|
|
map[string]string{"Authorization": "Bearer tok-1"})
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("want 204, got %d (body=%s)", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Body.Len() != 0 {
|
|
t.Fatalf("want empty body, got %q", rec.Body.String())
|
|
}
|
|
if ss.HasToken("tok-1") {
|
|
t.Fatal("session should have been deleted")
|
|
}
|
|
})
|
|
|
|
t.Run("missing token -> 401", func(t *testing.T) {
|
|
h, _, _ := setupAuthHandler()
|
|
rec := doRequest(h.Logout, http.MethodPost, "/logout", nil, nil)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("want 401, got %d", rec.Code)
|
|
}
|
|
assertContentTypeJSON(t, rec)
|
|
})
|
|
|
|
t.Run("unknown token -> 401", func(t *testing.T) {
|
|
h, _, _ := setupAuthHandler()
|
|
rec := doRequest(h.Logout, http.MethodPost, "/logout", nil,
|
|
map[string]string{"Authorization": "Bearer ghost"})
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("want 401, got %d", rec.Code)
|
|
}
|
|
assertContentTypeJSON(t, rec)
|
|
})
|
|
|
|
t.Run("second logout with same token -> 401", func(t *testing.T) {
|
|
h, _, ss := setupAuthHandler()
|
|
_ = ss.Create(nil, &models.Session{Token: "tok-2", UserID: "u1"})
|
|
|
|
rec1 := doRequest(h.Logout, http.MethodPost, "/logout", nil,
|
|
map[string]string{"Authorization": "Bearer tok-2"})
|
|
if rec1.Code != http.StatusNoContent {
|
|
t.Fatalf("first logout: want 204, got %d", rec1.Code)
|
|
}
|
|
rec2 := doRequest(h.Logout, http.MethodPost, "/logout", nil,
|
|
map[string]string{"Authorization": "Bearer tok-2"})
|
|
if rec2.Code != http.StatusUnauthorized {
|
|
t.Fatalf("second logout: want 401, got %d", rec2.Code)
|
|
}
|
|
assertContentTypeJSON(t, rec2)
|
|
})
|
|
}
|