feat(backend): jwt middleware, login with redis rate limit, /auth/me
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"booklib/internal/auth"
|
||||
"booklib/internal/db"
|
||||
"booklib/internal/redispkg"
|
||||
"booklib/internal/store"
|
||||
)
|
||||
|
||||
func setupAPI(t *testing.T) (*store.Store, http.Handler) {
|
||||
t.Helper()
|
||||
url := os.Getenv("DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
p, _ := db.Connect(ctx, url)
|
||||
if err := db.Migrate(ctx, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := store.New(p)
|
||||
p.Exec(ctx, "DELETE FROM reading_progress; DELETE FROM books; DELETE FROM libraries; DELETE FROM users")
|
||||
h, _ := auth.HashPassword("pw12345")
|
||||
_, err := st.CreateUser(ctx, "alice", h, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = st.CreateUser(ctx, "bob", h, "member")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := testCfg()
|
||||
r := NewRouter(cfg, st, redispkg.New(os.Getenv("REDIS_URL")))
|
||||
return st, r
|
||||
}
|
||||
|
||||
func do(h http.Handler, method, path, token string, body any) *httptest.ResponseRecorder {
|
||||
var r *bytes.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
} else {
|
||||
r = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, r)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestLoginMe(t *testing.T) {
|
||||
_, h := setupAPI(t)
|
||||
w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "alice", "password": "pw12345"})
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("login %d %s", w.Code, w.Body)
|
||||
}
|
||||
var tok struct{ Token string }
|
||||
json.Unmarshal(w.Body.Bytes(), &tok)
|
||||
if tok.Token == "" {
|
||||
t.Fatal("no token")
|
||||
}
|
||||
w = do(h, "GET", "/api/auth/me", tok.Token, nil)
|
||||
var me map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &me)
|
||||
if w.Code != 200 || me["username"] != "alice" || me["role"] != "admin" {
|
||||
t.Fatalf("me %d %s", w.Code, w.Body)
|
||||
}
|
||||
// 错密码 → 401 统一错误体
|
||||
w = do(h, "POST", "/api/auth/login", "", map[string]string{"username": "alice", "password": "nope"})
|
||||
if w.Code != 401 {
|
||||
t.Fatalf("want 401 got %d", w.Code)
|
||||
}
|
||||
// 无 token / 坏 token 访问受保护端点 → 401(me 已注册;books 路由 Task 10 才有)
|
||||
if w = do(h, "GET", "/api/auth/me", "", nil); w.Code != 401 {
|
||||
t.Fatal("me without token must 401")
|
||||
}
|
||||
if w = do(h, "GET", "/api/auth/me", "garbage", nil); w.Code != 401 {
|
||||
t.Fatal("me with bad token must 401")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberCannotWriteUsers(t *testing.T) {
|
||||
_, h := setupAPI(t)
|
||||
tok, _ := auth.Sign([]byte("s3cret"), 2, "member") // bob — 注意: 必须走真实登录拿 token
|
||||
w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "bob", "password": "pw12345"})
|
||||
var v struct{ Token string }
|
||||
json.Unmarshal(w.Body.Bytes(), &v)
|
||||
tok = v.Token
|
||||
w = do(h, "POST", "/api/users", tok, map[string]string{"username": "eve", "password": "pw12345", "role": "member"})
|
||||
if w.Code != 403 {
|
||||
t.Fatalf("member write users: want 403 got %d", w.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user