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
+40 -30
View File
@@ -5,13 +5,14 @@ import (
"net/http"
"strings"
"5803025048/W04/internal/store"
"5803025048/W04/internal/models"
)
type contextKey string
const UserIDKey contextKey = "user_id"
// GetUserID mengambil user id yang disimpan RequireAuth di context request.
func GetUserID(ctx context.Context) string {
if val, ok := ctx.Value(UserIDKey).(string); ok {
return val
@@ -19,35 +20,54 @@ func GetUserID(ctx context.Context) string {
return ""
}
type AuthMiddleware struct {
sessionStore *store.SessionStore
// SessionLookup adalah interface kecil yang dibutuhkan middleware:
// mencari session (dan user id-nya) dari sebuah token.
// *store.SessionStore memenuhi interface ini tanpa perubahan.
type SessionLookup interface {
Get(ctx context.Context, token string) (*models.Session, error)
}
func NewAuthMiddleware(sessionStore *store.SessionStore) *AuthMiddleware {
return &AuthMiddleware{sessionStore: sessionStore}
type AuthMiddleware struct {
sessions SessionLookup
}
func NewAuthMiddleware(sessions SessionLookup) *AuthMiddleware {
return &AuthMiddleware{sessions: sessions}
}
const bearerPrefix = "Bearer "
// BearerToken mengambil token dari header "Authorization: Bearer <token>".
// Mengembalikan false jika header tidak ada, skema bukan Bearer, atau token kosong.
func BearerToken(r *http.Request) (string, bool) {
h := r.Header.Get("Authorization")
if len(h) <= len(bearerPrefix) || !strings.EqualFold(h[:len(bearerPrefix)], bearerPrefix) {
return "", false
}
token := strings.TrimSpace(h[len(bearerPrefix):])
if token == "" {
return "", false
}
return token, true
}
func unauthorized(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"Unauthorized"}` + "\n"))
}
func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
token, ok := BearerToken(r)
if !ok {
unauthorized(w)
return
}
token := authHeader
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
token = authHeader[7:]
}
token = strings.TrimSpace(token)
if token == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
session, err := m.sessionStore.Get(r.Context(), token)
session, err := m.sessions.Get(r.Context(), token)
if err != nil || session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
unauthorized(w)
return
}
@@ -55,13 +75,3 @@ func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (m *AuthMiddleware) RequireAuthFunc(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
m.RequireAuth(next).ServeHTTP(w, r)
}
}
func RequireAuth(sessionStore *store.SessionStore) func(http.Handler) http.Handler {
return NewAuthMiddleware(sessionStore).RequireAuth
}
+131
View File
@@ -0,0 +1,131 @@
package middleware
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"5803025048/W04/internal/models"
)
// fakeLookup adalah fake SessionLookup buatan tangan.
type fakeLookup struct {
sessions map[string]string // token -> user id
nilToken string // token yang mengembalikan (nil, nil)
}
func (f *fakeLookup) Get(_ context.Context, token string) (*models.Session, error) {
if token == f.nilToken && token != "" {
return nil, nil
}
uid, ok := f.sessions[token]
if !ok {
return nil, errors.New("session not found")
}
return &models.Session{Token: token, UserID: uid}, nil
}
func TestRequireAuth(t *testing.T) {
lookup := &fakeLookup{
sessions: map[string]string{"valid-token": "user-42"},
nilToken: "nil-session-token",
}
// run menjalankan middleware dan mencatat apakah next dipanggil
// (lewat closure, bukan dari status code).
run := func(authz string, setHeader bool) (rec *httptest.ResponseRecorder, called bool, gotUserID string) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
gotUserID = GetUserID(r.Context())
w.WriteHeader(http.StatusOK)
})
h := NewAuthMiddleware(lookup).RequireAuth(next)
req := httptest.NewRequest(http.MethodGet, "/snippets", nil)
if setHeader {
req.Header.Set("Authorization", authz)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec, called, gotUserID
}
assert401 := func(t *testing.T, rec *httptest.ResponseRecorder, called bool) {
t.Helper()
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
if called {
t.Fatal("next handler must not be called")
}
}
t.Run("no Authorization header returns 401", func(t *testing.T) {
rec, called, _ := run("", false)
assert401(t, rec, called)
})
t.Run("Basic scheme returns 401", func(t *testing.T) {
rec, called, _ := run("Basic abc", true)
assert401(t, rec, called)
})
t.Run("Bearer with empty token returns 401", func(t *testing.T) {
rec, called, _ := run("Bearer ", true)
assert401(t, rec, called)
})
t.Run("Bearer with only spaces returns 401", func(t *testing.T) {
rec, called, _ := run("Bearer ", true)
assert401(t, rec, called)
})
t.Run("bare token without scheme returns 401", func(t *testing.T) {
rec, called, _ := run("valid-token", true)
assert401(t, rec, called)
})
t.Run("unknown token returns 401", func(t *testing.T) {
rec, called, _ := run("Bearer does-not-exist", true)
assert401(t, rec, called)
})
t.Run("lookup returning nil session returns 401", func(t *testing.T) {
rec, called, _ := run("Bearer nil-session-token", true)
assert401(t, rec, called)
})
t.Run("valid token calls next with user id in context", func(t *testing.T) {
rec, called, uid := run("Bearer valid-token", true)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !called {
t.Fatal("next handler must be called for a valid token")
}
if uid != "user-42" {
t.Fatalf("user id in context = %q, want %q", uid, "user-42")
}
})
t.Run("401 response is JSON with parsable body", func(t *testing.T) {
rec, _, _ := run("Bearer does-not-exist", true)
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Fatalf("Content-Type = %q, want application/json", ct)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("401 body is not valid JSON: %v (body=%q)", err, rec.Body.String())
}
})
t.Run("GetUserID without value returns empty string", func(t *testing.T) {
if got := GetUserID(context.Background()); got != "" {
t.Fatalf("GetUserID = %q, want empty string", got)
}
})
}