266 lines
9.2 KiB
Go
266 lines
9.2 KiB
Go
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)
|
|
})
|
|
}
|