Assignment 4: tests, interface refactor, e2e suite
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user