Tuas W06
This commit is contained in:
@@ -8,17 +8,18 @@ import (
|
||||
"time"
|
||||
|
||||
"5803025020/w04/internal/auth"
|
||||
"5803025020/w04/internal/httpjson"
|
||||
"5803025020/w04/internal/models"
|
||||
"5803025020/w04/internal/store"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
userStore *store.UserStore
|
||||
sessionStore *store.SessionStore
|
||||
userStore UserStore
|
||||
sessionStore SessionStore
|
||||
}
|
||||
|
||||
func NewAuthHandler(userStore *store.UserStore, sessionStore *store.SessionStore) *AuthHandler {
|
||||
func NewAuthHandler(userStore UserStore, sessionStore SessionStore) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
userStore: userStore,
|
||||
sessionStore: sessionStore,
|
||||
@@ -32,25 +33,25 @@ type authRequest struct {
|
||||
|
||||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
httpjson.WriteError(w, http.StatusMethodNotAllowed, "Method Not Allowed")
|
||||
return
|
||||
}
|
||||
|
||||
var req authRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
httpjson.WriteError(w, http.StatusBadRequest, "Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if len(req.Username) < 3 || len(req.Username) > 32 || req.Password == "" {
|
||||
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||
httpjson.WriteError(w, http.StatusBadRequest, "Invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
httpjson.WriteError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,10 +63,10 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := h.userStore.Create(r.Context(), user); err != nil {
|
||||
if errors.Is(err, store.ErrUsernameTaken) {
|
||||
http.Error(w, "Username already exists", http.StatusConflict)
|
||||
httpjson.WriteError(w, http.StatusConflict, "Username already exists")
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
httpjson.WriteError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -76,25 +77,25 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
httpjson.WriteError(w, http.StatusMethodNotAllowed, "Method Not Allowed")
|
||||
return
|
||||
}
|
||||
|
||||
var req authRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
httpjson.WriteError(w, http.StatusBadRequest, "Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userStore.GetByUsername(r.Context(), req.Username)
|
||||
if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) {
|
||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||
httpjson.WriteError(w, http.StatusUnauthorized, "Invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken()
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
httpjson.WriteError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -105,7 +106,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.sessionStore.Create(r.Context(), session); err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
httpjson.WriteError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -116,28 +117,32 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
httpjson.WriteError(w, http.StatusMethodNotAllowed, "Method Not Allowed")
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
httpjson.WriteError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
token := authHeader
|
||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
token = authHeader[7:]
|
||||
token = authHeader[len("Bearer "):]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
httpjson.WriteError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.sessionStore.Delete(r.Context(), token); err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
if errors.Is(err, store.ErrSessionNotFound) {
|
||||
httpjson.WriteError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
httpjson.WriteError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"5803025020/w04/internal/models"
|
||||
"5803025020/w04/internal/store"
|
||||
)
|
||||
|
||||
// fakeUserStore is a hand-written in-memory implementation of UserStore.
|
||||
type fakeUserStore struct {
|
||||
mu sync.Mutex
|
||||
byName map[string]*models.User
|
||||
}
|
||||
|
||||
func newFakeUserStore() *fakeUserStore {
|
||||
return &fakeUserStore{byName: map[string]*models.User{}}
|
||||
}
|
||||
|
||||
func (f *fakeUserStore) Create(ctx context.Context, user *models.User) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, exists := f.byName[user.Username]; exists {
|
||||
return store.ErrUsernameTaken
|
||||
}
|
||||
cp := *user
|
||||
f.byName[user.Username] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
u, ok := f.byName[username]
|
||||
if !ok {
|
||||
return nil, store.ErrUserNotFound
|
||||
}
|
||||
cp := *u
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// fakeSessionStore is a hand-written in-memory implementation of SessionStore.
|
||||
type fakeSessionStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*models.Session
|
||||
}
|
||||
|
||||
func newFakeSessionStore() *fakeSessionStore {
|
||||
return &fakeSessionStore{sessions: map[string]*models.Session{}}
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) Create(ctx context.Context, session *models.Session) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
cp := *session
|
||||
f.sessions[session.Token] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) Delete(ctx context.Context, token string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.sessions[token]; !ok {
|
||||
return store.ErrSessionNotFound
|
||||
}
|
||||
delete(f.sessions, token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) has(token string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
_, ok := f.sessions[token]
|
||||
return ok
|
||||
}
|
||||
|
||||
// fakeSnippetStore is a hand-written in-memory implementation of SnippetStore.
|
||||
type fakeSnippetStore struct {
|
||||
mu sync.Mutex
|
||||
snippets map[string]*models.Snippet
|
||||
}
|
||||
|
||||
func newFakeSnippetStore() *fakeSnippetStore {
|
||||
return &fakeSnippetStore{snippets: map[string]*models.Snippet{}}
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) Create(ctx context.Context, snippet *models.Snippet) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
cp := *snippet
|
||||
f.snippets[snippet.ID] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) GetByID(ctx context.Context, id string) (*models.Snippet, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
sn, ok := f.snippets[id]
|
||||
if !ok {
|
||||
return nil, store.ErrSnippetNotFound
|
||||
}
|
||||
cp := *sn
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) GetByUserID(ctx context.Context, userID string) ([]*models.Snippet, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
result := make([]*models.Snippet, 0)
|
||||
for _, sn := range f.snippets {
|
||||
if sn.UserID == userID {
|
||||
cp := *sn
|
||||
result = append(result, &cp)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) Delete(ctx context.Context, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.snippets[id]; !ok {
|
||||
return store.ErrSnippetNotFound
|
||||
}
|
||||
delete(f.snippets, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) has(id string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
_, ok := f.snippets[id]
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"5803025020/w04/internal/models"
|
||||
)
|
||||
|
||||
type UserStore interface {
|
||||
Create(ctx context.Context, user *models.User) error
|
||||
GetByUsername(ctx context.Context, username string) (*models.User, error)
|
||||
}
|
||||
|
||||
type SessionStore interface {
|
||||
Create(ctx context.Context, session *models.Session) error
|
||||
|
||||
Delete(ctx context.Context, token string) error
|
||||
}
|
||||
|
||||
|
||||
type SnippetStore interface {
|
||||
Create(ctx context.Context, snippet *models.Snippet) error
|
||||
|
||||
GetByID(ctx context.Context, id string) (*models.Snippet, error)
|
||||
GetByUserID(ctx context.Context, userID string) ([]*models.Snippet, error)
|
||||
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"5803025020/w04/internal/middleware"
|
||||
"5803025020/w04/internal/models"
|
||||
)
|
||||
|
||||
func mustJSONBody(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal request body: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// authedRequest builds a request that already carries an authenticated user
|
||||
// id in its context, simulating what RequireAuth does before calling the
|
||||
// handler. This lets snippet handler tests run without any middleware.
|
||||
func authedRequest(method, path string, body []byte, userID string) *http.Request {
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
req = httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), middleware.UserIDKey, userID)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func TestCreateSnippet(t *testing.T) {
|
||||
t.Run("success returns 201 with exactly id, title, content", func(t *testing.T) {
|
||||
ss := newFakeSnippetStore()
|
||||
h := NewSnippetHandler(ss)
|
||||
req := authedRequest(http.MethodPost, "/snippets", mustJSONBody(t, map[string]string{"title": "hello", "content": "world"}), "user-1")
|
||||
rec := httptest.NewRecorder()
|
||||
h.Create(rec, req)
|
||||
|
||||
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)
|
||||
}
|
||||
wantKeys := []string{"id", "title", "content"}
|
||||
if len(body) != len(wantKeys) {
|
||||
t.Fatalf("expected exactly keys %v, got %v", wantKeys, body)
|
||||
}
|
||||
for _, k := range wantKeys {
|
||||
if _, ok := body[k]; !ok {
|
||||
t.Fatalf("missing key %q in response: %v", k, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty title returns 400", func(t *testing.T) {
|
||||
ss := newFakeSnippetStore()
|
||||
h := NewSnippetHandler(ss)
|
||||
req := authedRequest(http.MethodPost, "/snippets", mustJSONBody(t, map[string]string{"title": "", "content": "world"}), "user-1")
|
||||
rec := httptest.NewRecorder()
|
||||
h.Create(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("empty content returns 400", func(t *testing.T) {
|
||||
ss := newFakeSnippetStore()
|
||||
h := NewSnippetHandler(ss)
|
||||
req := authedRequest(http.MethodPost, "/snippets", mustJSONBody(t, map[string]string{"title": "hello", "content": ""}), "user-1")
|
||||
rec := httptest.NewRecorder()
|
||||
h.Create(rec, req)
|
||||
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) {
|
||||
ss := newFakeSnippetStore()
|
||||
h := NewSnippetHandler(ss)
|
||||
req := authedRequest(http.MethodPost, "/snippets", []byte("{bad"), "user-1")
|
||||
rec := httptest.NewRecorder()
|
||||
h.Create(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("unauthenticated returns 401", func(t *testing.T) {
|
||||
ss := newFakeSnippetStore()
|
||||
h := NewSnippetHandler(ss)
|
||||
req := httptest.NewRequest(http.MethodPost, "/snippets", bytes.NewReader(mustJSONBody(t, map[string]string{"title": "hello", "content": "world"})))
|
||||
rec := httptest.NewRecorder()
|
||||
h.Create(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetSnippet(t *testing.T) {
|
||||
newSeeded := func() (*SnippetHandler, *fakeSnippetStore) {
|
||||
ss := newFakeSnippetStore()
|
||||
_ = ss.Create(context.Background(), &models.Snippet{ID: "s1", Title: "hello", Content: "world", UserID: "owner-1"})
|
||||
return NewSnippetHandler(ss), ss
|
||||
}
|
||||
|
||||
t.Run("existing id returns 200 without any Authorization header", func(t *testing.T) {
|
||||
h, _ := newSeeded()
|
||||
req := httptest.NewRequest(http.MethodGet, "/snippets/s1", nil)
|
||||
req.SetPathValue("id", "s1")
|
||||
rec := httptest.NewRecorder()
|
||||
h.Get(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("missing id returns 404", func(t *testing.T) {
|
||||
h, _ := newSeeded()
|
||||
req := httptest.NewRequest(http.MethodGet, "/snippets/does-not-exist", nil)
|
||||
req.SetPathValue("id", "does-not-exist")
|
||||
rec := httptest.NewRecorder()
|
||||
h.Get(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("response has exactly the keys id, title, content", func(t *testing.T) {
|
||||
h, _ := newSeeded()
|
||||
req := httptest.NewRequest(http.MethodGet, "/snippets/s1", nil)
|
||||
req.SetPathValue("id", "s1")
|
||||
rec := httptest.NewRecorder()
|
||||
h.Get(rec, req)
|
||||
assertJSONContentType(t, rec)
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("expected JSON body: %v", err)
|
||||
}
|
||||
wantKeys := []string{"id", "title", "content"}
|
||||
if len(decoded) != len(wantKeys) {
|
||||
t.Fatalf("expected exactly keys %v, got %v", wantKeys, decoded)
|
||||
}
|
||||
for _, k := range wantKeys {
|
||||
if _, ok := decoded[k]; !ok {
|
||||
t.Fatalf("missing key %q in response: %v", k, decoded)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListSnippets(t *testing.T) {
|
||||
t.Run("only the caller's snippets are returned", func(t *testing.T) {
|
||||
ss := newFakeSnippetStore()
|
||||
ctx := context.Background()
|
||||
_ = ss.Create(ctx, &models.Snippet{ID: "s1", Title: "t1", Content: "c1", UserID: "user-1"})
|
||||
_ = ss.Create(ctx, &models.Snippet{ID: "s2", Title: "t2", Content: "c2", UserID: "user-2"})
|
||||
h := NewSnippetHandler(ss)
|
||||
req := authedRequest(http.MethodGet, "/snippets", nil, "user-1")
|
||||
rec := httptest.NewRecorder()
|
||||
h.List(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
var got []map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("expected JSON array: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected exactly 1 snippet, got %d: %v", len(got), got)
|
||||
}
|
||||
if got[0]["id"] != "s1" {
|
||||
t.Fatalf("expected snippet s1, got %v", got[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no snippets returns a literal empty array, not null", func(t *testing.T) {
|
||||
ss := newFakeSnippetStore()
|
||||
h := NewSnippetHandler(ss)
|
||||
req := authedRequest(http.MethodGet, "/snippets", nil, "user-with-nothing")
|
||||
rec := httptest.NewRecorder()
|
||||
h.List(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
body := strings.TrimSpace(rec.Body.String())
|
||||
if body != "[]" {
|
||||
t.Fatalf("expected literal [], got %q", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unauthenticated returns 401", func(t *testing.T) {
|
||||
ss := newFakeSnippetStore()
|
||||
h := NewSnippetHandler(ss)
|
||||
req := httptest.NewRequest(http.MethodGet, "/snippets", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.List(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("each element has exactly the three keys", func(t *testing.T) {
|
||||
ss := newFakeSnippetStore()
|
||||
_ = ss.Create(context.Background(), &models.Snippet{ID: "s1", Title: "t1", Content: "c1", UserID: "user-1"})
|
||||
h := NewSnippetHandler(ss)
|
||||
req := authedRequest(http.MethodGet, "/snippets", nil, "user-1")
|
||||
rec := httptest.NewRecorder()
|
||||
h.List(rec, req)
|
||||
assertJSONContentType(t, rec)
|
||||
|
||||
var got []map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("expected JSON array: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 element, got %d", len(got))
|
||||
}
|
||||
wantKeys := []string{"id", "title", "content"}
|
||||
if len(got[0]) != len(wantKeys) {
|
||||
t.Fatalf("expected exactly keys %v, got %v", wantKeys, got[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteSnippet(t *testing.T) {
|
||||
newSeeded := func() (*SnippetHandler, *fakeSnippetStore) {
|
||||
ss := newFakeSnippetStore()
|
||||
_ = ss.Create(context.Background(), &models.Snippet{ID: "s1", Title: "t1", Content: "c1", UserID: "owner-1"})
|
||||
return NewSnippetHandler(ss), ss
|
||||
}
|
||||
|
||||
t.Run("owner deleting returns 204 with empty body and snippet is gone", func(t *testing.T) {
|
||||
h, ss := newSeeded()
|
||||
req := authedRequest(http.MethodDelete, "/snippets/s1", nil, "owner-1")
|
||||
req.SetPathValue("id", "s1")
|
||||
rec := httptest.NewRecorder()
|
||||
h.Delete(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("s1") {
|
||||
t.Fatal("expected snippet to be removed from the fake store")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-owner returns 403 and snippet still present", func(t *testing.T) {
|
||||
h, ss := newSeeded()
|
||||
req := authedRequest(http.MethodDelete, "/snippets/s1", nil, "someone-else")
|
||||
req.SetPathValue("id", "s1")
|
||||
rec := httptest.NewRecorder()
|
||||
h.Delete(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
if !ss.has("s1") {
|
||||
t.Fatal("expected snippet to still be present")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nonexistent id returns 404", func(t *testing.T) {
|
||||
h, _ := newSeeded()
|
||||
req := authedRequest(http.MethodDelete, "/snippets/does-not-exist", nil, "owner-1")
|
||||
req.SetPathValue("id", "does-not-exist")
|
||||
rec := httptest.NewRecorder()
|
||||
h.Delete(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("unauthenticated returns 401", func(t *testing.T) {
|
||||
h, _ := newSeeded()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/snippets/s1", nil)
|
||||
req.SetPathValue("id", "s1")
|
||||
rec := httptest.NewRecorder()
|
||||
h.Delete(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("non-owner on a nonexistent id returns 404, not 403", func(t *testing.T) {
|
||||
h, _ := newSeeded()
|
||||
req := authedRequest(http.MethodDelete, "/snippets/does-not-exist", nil, "someone-else")
|
||||
req.SetPathValue("id", "does-not-exist")
|
||||
rec := httptest.NewRecorder()
|
||||
h.Delete(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user