Assignment 4: tests, interface refactor, e2e suite

This commit is contained in:
Zagar Nashrullah
2026-10-03 19:35:30 +07:00
parent 6e7e35d9a5
commit 004bf4e292
25 changed files with 2644 additions and 151 deletions
+320
View File
@@ -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)
}
})
}