package handler import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "5803025020/w04/internal/auth" "5803025020/w04/internal/models" ) func newTestAuthHandler() (*AuthHandler, *fakeUserStore, *fakeSessionStore) { us := newFakeUserStore() ss := newFakeSessionStore() return NewAuthHandler(us, ss), us, ss } // doJSONRequest builds a request whose body is either a Go value (marshalled // to JSON) or a raw string (used as-is, to exercise malformed-JSON cases). func doJSONRequest(h http.HandlerFunc, method, path string, body any) *httptest.ResponseRecorder { var raw []byte switch v := body.(type) { case string: raw = []byte(v) default: raw, _ = json.Marshal(v) } req := httptest.NewRequest(method, path, bytes.NewReader(raw)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h(rec, req) return rec } // assertJSONContentType is used on every handler test subtest that inspects // a non-204 response, per the spec's blanket requirement. func assertJSONContentType(t *testing.T, rec *httptest.ResponseRecorder) { t.Helper() ct := rec.Header().Get("Content-Type") if !strings.HasPrefix(ct, "application/json") { t.Fatalf("expected Content-Type application/json, got %q", ct) } } func TestRegister(t *testing.T) { t.Run("success returns 201 with id and echoed username", func(t *testing.T) { h, _, _ := newTestAuthHandler() rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{ "username": "alice", "password": "password123", }) if rec.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) } assertJSONContentType(t, rec) var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("expected JSON body: %v", err) } if id, _ := body["id"].(string); id == "" { t.Fatal("expected non-empty id in response") } if body["username"] != "alice" { t.Fatalf("expected username %q, got %v", "alice", body["username"]) } }) t.Run("duplicate username returns 409", func(t *testing.T) { h, _, _ := newTestAuthHandler() doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "bob", "password": "password123"}) rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "bob", "password": "anotherpass"}) if rec.Code != http.StatusConflict { t.Fatalf("expected 409, got %d: %s", rec.Code, rec.Body.String()) } assertJSONContentType(t, rec) }) t.Run("empty username returns 400", func(t *testing.T) { h, _, _ := newTestAuthHandler() rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "", "password": "password123"}) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } assertJSONContentType(t, rec) }) t.Run("empty password returns 400", func(t *testing.T) { h, _, _ := newTestAuthHandler() rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "charlie", "password": ""}) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } assertJSONContentType(t, rec) }) t.Run("username of 2 characters returns 400", func(t *testing.T) { h, _, _ := newTestAuthHandler() rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "ab", "password": "password123"}) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } assertJSONContentType(t, rec) }) t.Run("username of 33 characters returns 400", func(t *testing.T) { h, _, _ := newTestAuthHandler() longUsername := strings.Repeat("a", 33) rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": longUsername, "password": "password123"}) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } assertJSONContentType(t, rec) }) t.Run("malformed JSON returns 400", func(t *testing.T) { h, _, _ := newTestAuthHandler() rec := doJSONRequest(h.Register, http.MethodPost, "/register", "{not valid json") if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } assertJSONContentType(t, rec) }) t.Run("success body contains no password or password_hash key, nor the plaintext", func(t *testing.T) { h, _, _ := newTestAuthHandler() rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "dana", "password": "supersecretpw"}) if rec.Code != http.StatusCreated { t.Fatalf("expected 201, got %d", rec.Code) } assertJSONContentType(t, rec) lower := strings.ToLower(rec.Body.String()) if strings.Contains(lower, "password") { t.Fatalf("response body must not contain any password-related key: %s", rec.Body.String()) } if strings.Contains(rec.Body.String(), "supersecretpw") { t.Fatal("response body must not contain the plaintext password") } }) } func TestLogin(t *testing.T) { h, us, _ := newTestAuthHandler() ctx := context.Background() hash, err := auth.HashPassword("correcthorse") if err != nil { t.Fatalf("failed to hash seed password: %v", err) } if err := us.Create(ctx, &models.User{ID: "user-1", Username: "eve", PasswordHash: hash}); err != nil { t.Fatalf("failed to seed user: %v", err) } var wrongPasswordBody, unknownUserBody string t.Run("success returns 200 with non-empty session_token", func(t *testing.T) { rec := doJSONRequest(h.Login, http.MethodPost, "/login", map[string]string{"username": "eve", "password": "correcthorse"}) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } assertJSONContentType(t, rec) var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("expected JSON body: %v", err) } tok, _ := body["session_token"].(string) if tok == "" { t.Fatal("expected non-empty session_token") } }) t.Run("wrong password returns 401", func(t *testing.T) { rec := doJSONRequest(h.Login, http.MethodPost, "/login", map[string]string{"username": "eve", "password": "wrongpass"}) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", rec.Code) } assertJSONContentType(t, rec) wrongPasswordBody = rec.Body.String() }) t.Run("unknown username returns 401", func(t *testing.T) { rec := doJSONRequest(h.Login, http.MethodPost, "/login", map[string]string{"username": "ghost", "password": "whatever"}) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", rec.Code) } assertJSONContentType(t, rec) unknownUserBody = rec.Body.String() }) t.Run("401 bodies for wrong password and unknown user are byte-for-byte identical", func(t *testing.T) { if wrongPasswordBody == "" || unknownUserBody == "" { t.Fatal("prior subtests did not populate response bodies") } if wrongPasswordBody != unknownUserBody { t.Fatalf("expected identical bodies, got %q vs %q", wrongPasswordBody, unknownUserBody) } }) t.Run("malformed JSON returns 400", func(t *testing.T) { rec := doJSONRequest(h.Login, http.MethodPost, "/login", "{bad json") if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } assertJSONContentType(t, rec) }) } func TestLogout(t *testing.T) { h, _, ss := newTestAuthHandler() ctx := context.Background() validToken := "validtoken1234567890123456789012" if err := ss.Create(ctx, &models.Session{Token: validToken, UserID: "user-1"}); err != nil { t.Fatalf("failed to seed session: %v", err) } t.Run("valid token returns 204 with empty body and removes the session", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/logout", nil) req.Header.Set("Authorization", "Bearer "+validToken) rec := httptest.NewRecorder() h.Logout(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String()) } // 204 is exempt from the Content-Type:application/json requirement. if rec.Body.Len() != 0 { t.Fatalf("expected empty body, got %q", rec.Body.String()) } if ss.has(validToken) { t.Fatal("expected session to be removed from the fake store") } }) t.Run("missing token returns 401", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/logout", nil) rec := httptest.NewRecorder() h.Logout(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", rec.Code) } assertJSONContentType(t, rec) }) t.Run("unknown token returns 401", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/logout", nil) req.Header.Set("Authorization", "Bearer not-a-real-token-000000000000") rec := httptest.NewRecorder() h.Logout(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", rec.Code) } assertJSONContentType(t, rec) }) t.Run("second logout with the same token returns 401", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/logout", nil) req.Header.Set("Authorization", "Bearer "+validToken) rec := httptest.NewRecorder() h.Logout(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("expected 401 on second logout, got %d", rec.Code) } assertJSONContentType(t, rec) }) }