321 lines
9.8 KiB
Go
321 lines
9.8 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|