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