package handler import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "5803025048/W04/internal/auth" "5803025048/W04/internal/models" ) func credsBody(username, password string) string { b, _ := json.Marshal(map[string]string{"username": username, "password": password}) return string(b) } func TestRegister(t *testing.T) { const password = "s3cret-pass-xyz" newHandler := func() (*AuthHandler, *fakeUserStore) { us := newFakeUserStore() return NewAuthHandler(us, newFakeSessionStore()), us } register := func(h *AuthHandler, body string) *httptest.ResponseRecorder { rec := httptest.NewRecorder() h.Register(rec, newJSONRequest(http.MethodPost, "/register", body)) return rec } t.Run("success returns 201 with id and username", func(t *testing.T) { h, _ := newHandler() rec := register(h, credsBody("alice", password)) if rec.Code != http.StatusCreated { t.Fatalf("status = %d, want 201", rec.Code) } assertJSONContentType(t, rec) m := decodeMap(t, rec.Body.Bytes()) if id, ok := m["id"].(string); !ok || id == "" { t.Fatalf("id must be a non-empty string, got %v", m["id"]) } if m["username"] != "alice" { t.Fatalf("username = %v, want alice", m["username"]) } }) t.Run("duplicate username returns 409", func(t *testing.T) { h, _ := newHandler() if rec := register(h, credsBody("alice", password)); rec.Code != http.StatusCreated { t.Fatalf("first register status = %d, want 201", rec.Code) } rec := register(h, credsBody("alice", password)) if rec.Code != http.StatusConflict { t.Fatalf("status = %d, want 409", rec.Code) } assertJSONContentType(t, rec) }) badCases := []struct { name string body string }{ {"empty username returns 400", credsBody("", password)}, {"empty password returns 400", credsBody("alice", "")}, {"username of 2 characters returns 400", credsBody("ab", password)}, {"username of 33 characters returns 400", credsBody(strings.Repeat("a", 33), password)}, {"malformed JSON returns 400", `{"username": "alice", "password":`}, {"empty body returns 400", ``}, {"JSON array body returns 400", `["alice","pw"]`}, {"password longer than bcrypt limit returns 400", credsBody("alice", strings.Repeat("p", 73))}, } for _, tc := range badCases { t.Run(tc.name, func(t *testing.T) { h, us := newHandler() rec := register(h, tc.body) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rec.Code) } assertJSONContentType(t, rec) if len(us.users) != 0 { t.Fatal("no user must be stored for an invalid request") } }) } t.Run("usernames of exactly 3 and 32 characters are accepted", func(t *testing.T) { h, _ := newHandler() for _, name := range []string{"abc", strings.Repeat("b", 32)} { if rec := register(h, credsBody(name, password)); rec.Code != http.StatusCreated { t.Fatalf("username %q: status = %d, want 201", name, rec.Code) } } }) t.Run("success body contains no password material", func(t *testing.T) { h, _ := newHandler() rec := register(h, credsBody("alice", password)) m := decodeMap(t, rec.Body.Bytes()) for k := range m { if strings.Contains(strings.ToLower(k), "password") { t.Fatalf("response must not contain a password-like key, found %q", k) } } if strings.Contains(rec.Body.String(), password) { t.Fatal("response must not contain the plaintext password") } }) t.Run("store failure returns 500", func(t *testing.T) { h, us := newHandler() us.createErr = errors.New("db down") rec := register(h, credsBody("alice", password)) if rec.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500", rec.Code) } assertJSONContentType(t, rec) }) } func TestLogin(t *testing.T) { const username = "alice" const password = "correct-horse" setup := func(t *testing.T) (*AuthHandler, *fakeUserStore, *fakeSessionStore) { t.Helper() us, ss := newFakeUserStore(), newFakeSessionStore() hash, err := auth.HashPassword(password) if err != nil { t.Fatalf("HashPassword: %v", err) } if err := us.Create(context.Background(), &models.User{ID: "u1", Username: username, PasswordHash: hash}); err != nil { t.Fatalf("seed user: %v", err) } return NewAuthHandler(us, ss), us, ss } login := func(h *AuthHandler, body string) *httptest.ResponseRecorder { rec := httptest.NewRecorder() h.Login(rec, newJSONRequest(http.MethodPost, "/login", body)) return rec } t.Run("success returns 200 with session_token", func(t *testing.T) { h, _, ss := setup(t) rec := login(h, credsBody(username, password)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } assertJSONContentType(t, rec) m := decodeMap(t, rec.Body.Bytes()) tok, ok := m["session_token"].(string) if !ok || tok == "" { t.Fatalf("session_token must be a non-empty string, got %v", m["session_token"]) } if !ss.has(tok) { t.Fatal("the returned token must be stored in the session store") } }) t.Run("wrong password returns 401", func(t *testing.T) { h, _, _ := setup(t) rec := login(h, credsBody(username, "wrong-password")) if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } assertJSONContentType(t, rec) }) t.Run("unknown username returns 401", func(t *testing.T) { h, _, _ := setup(t) rec := login(h, credsBody("nobody", password)) if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } assertJSONContentType(t, rec) }) t.Run("401 bodies for wrong password and unknown user are identical", func(t *testing.T) { h, _, _ := setup(t) wrong := login(h, credsBody(username, "wrong-password")) unknown := login(h, credsBody("nobody", password)) if wrong.Code != http.StatusUnauthorized || unknown.Code != http.StatusUnauthorized { t.Fatalf("statuses = %d and %d, want 401 and 401", wrong.Code, unknown.Code) } if wrong.Body.String() != unknown.Body.String() { t.Fatalf("bodies differ:\n wrong password: %q\n unknown user: %q", wrong.Body.String(), unknown.Body.String()) } }) t.Run("malformed JSON returns 400", func(t *testing.T) { h, _, _ := setup(t) rec := login(h, `{"username":`) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rec.Code) } assertJSONContentType(t, rec) }) t.Run("empty body and JSON array return 400", func(t *testing.T) { h, _, _ := setup(t) for _, body := range []string{``, `[1,2,3]`} { if rec := login(h, body); rec.Code != http.StatusBadRequest { t.Fatalf("body %q: status = %d, want 400", body, rec.Code) } } }) t.Run("user store failure returns 500", func(t *testing.T) { h, us, _ := setup(t) us.getErr = errors.New("db down") rec := login(h, credsBody(username, password)) if rec.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500", rec.Code) } }) t.Run("session store failure returns 500", func(t *testing.T) { h, _, ss := setup(t) ss.createErr = errors.New("db down") rec := login(h, credsBody(username, password)) if rec.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500", rec.Code) } }) } func TestLogout(t *testing.T) { setup := func() (*AuthHandler, *fakeSessionStore) { ss := newFakeSessionStore() ss.sessions["tok-valid"] = &models.Session{Token: "tok-valid", UserID: "u1"} ss.sessions["tok-other"] = &models.Session{Token: "tok-other", UserID: "u2"} return NewAuthHandler(newFakeUserStore(), ss), ss } logout := func(h *AuthHandler, authz string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/logout", nil) if authz != "" { req.Header.Set("Authorization", authz) } rec := httptest.NewRecorder() h.Logout(rec, req) return rec } t.Run("valid token returns 204 and removes the session", func(t *testing.T) { h, ss := setup() rec := logout(h, "Bearer tok-valid") if rec.Code != http.StatusNoContent { t.Fatalf("status = %d, want 204", rec.Code) } if rec.Body.Len() != 0 { t.Fatalf("body must be empty, got %q", rec.Body.String()) } if ss.has("tok-valid") { t.Fatal("session store must no longer contain the token") } if !ss.has("tok-other") { t.Fatal("other sessions must not be deleted") } }) t.Run("missing token returns 401", func(t *testing.T) { h, _ := setup() rec := logout(h, "") if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } assertJSONContentType(t, rec) }) t.Run("unknown token returns 401", func(t *testing.T) { h, _ := setup() rec := logout(h, "Bearer not-a-real-token") if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } assertJSONContentType(t, rec) }) t.Run("second logout with the same token returns 401", func(t *testing.T) { h, _ := setup() if rec := logout(h, "Bearer tok-valid"); rec.Code != http.StatusNoContent { t.Fatalf("first logout status = %d, want 204", rec.Code) } rec := logout(h, "Bearer tok-valid") if rec.Code != http.StatusUnauthorized { t.Fatalf("second logout status = %d, want 401", rec.Code) } }) t.Run("Basic scheme returns 401 and keeps the session", func(t *testing.T) { h, ss := setup() rec := logout(h, "Basic tok-valid") if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } if !ss.has("tok-valid") { t.Fatal("session must not be deleted by a non-Bearer header") } }) t.Run("session lookup failure returns 500", func(t *testing.T) { h, ss := setup() ss.getErr = errors.New("db down") rec := logout(h, "Bearer tok-valid") if rec.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500", rec.Code) } }) t.Run("session delete failure returns 500", func(t *testing.T) { h, ss := setup() ss.deleteErr = errors.New("db down") rec := logout(h, "Bearer tok-valid") if rec.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500", rec.Code) } }) }