package admin import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "oneblog/internal/config" "oneblog/internal/db" "oneblog/internal/model" "oneblog/internal/store" ) func newTestAPI(t *testing.T) (*API, http.Handler) { t.Helper() d, err := db.Open("sqlite", ":memory:") if err != nil { t.Fatalf("open sqlite: %v", err) } t.Cleanup(func() { d.Close() }) st, err := store.New(d) if err != nil { t.Fatalf("store: %v", err) } cfg := &config.Config{AdminUser: "admin", AdminPass: "s3cret"} a := NewAPI(st, cfg, NewSessions("test-secret", time.Hour)) return a, a.Routes() } func login(t *testing.T, h http.Handler, user, pass string) *httptest.ResponseRecorder { t.Helper() body, _ := json.Marshal(loginRequest{Username: user, Password: pass}) req := httptest.NewRequest(http.MethodPost, "/api/admin/login", strings.NewReader(string(body))) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func TestGuardRejectsAnonymous(t *testing.T) { _, h := newTestAPI(t) for _, path := range []string{"/api/admin/posts", "/api/admin/settings", "/api/admin/dashboard"} { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) if rec.Code != http.StatusUnauthorized { t.Errorf("%s: got %d, want 401", path, rec.Code) } } } func TestLoginIssuesUsableSession(t *testing.T) { _, h := newTestAPI(t) rec := login(t, h, "admin", "wrong") if rec.Code != http.StatusUnauthorized { t.Fatalf("wrong password: got %d, want 401", rec.Code) } rec = login(t, h, "admin", "s3cret") if rec.Code != http.StatusOK { t.Fatalf("login: got %d, want 200", rec.Code) } var out struct { Token string `json:"token"` } if err := json.NewDecoder(rec.Body).Decode(&out); err != nil || out.Token == "" { t.Fatalf("no token in response: %v", err) } // Bearer 通道 req := httptest.NewRequest(http.MethodGet, "/api/admin/posts", nil) req.Header.Set("Authorization", "Bearer "+out.Token) rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, req) if rec2.Code != http.StatusOK { t.Fatalf("bearer posts: got %d, want 200", rec2.Code) } // cookie 通道 req = httptest.NewRequest(http.MethodGet, "/api/admin/posts", nil) req.AddCookie(&http.Cookie{Name: "one_session", Value: out.Token}) rec3 := httptest.NewRecorder() h.ServeHTTP(rec3, req) if rec3.Code != http.StatusOK { t.Fatalf("cookie posts: got %d, want 200", rec3.Code) } } func TestLoginRateLimitedAfterRepeatedFailures(t *testing.T) { _, h := newTestAPI(t) var rec *httptest.ResponseRecorder for i := 0; i < maxLoginFails+1; i++ { rec = login(t, h, "admin", "bad") } if rec.Code != http.StatusTooManyRequests { t.Fatalf("after %d failures: got %d, want 429", maxLoginFails+1, rec.Code) } // 限速期间即使口令正确也被拒 rec = login(t, h, "admin", "s3cret") if rec.Code != http.StatusTooManyRequests { t.Fatalf("locked out login: got %d, want 429", rec.Code) } } func TestSecureCookieBehindTLSProxy(t *testing.T) { _, h := newTestAPI(t) body, _ := json.Marshal(loginRequest{Username: "admin", Password: "s3cret"}) req := httptest.NewRequest(http.MethodPost, "/api/admin/login", strings.NewReader(string(body))) req.Header.Set("X-Forwarded-Proto", "https") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("login: %d", rec.Code) } cookie := rec.Header().Get("Set-Cookie") if !strings.Contains(cookie, "Secure") { t.Fatalf("expected Secure flag over https proxy, got: %s", cookie) } } // ?order= 直接来自查询串并拼进 SQL,注入串必须被白名单挡掉且不影响数据。 func TestOrderParamInjectionIsNeutralized(t *testing.T) { a, h := newTestAPI(t) if _, err := a.Store.Create(model.PostInput{Title: "one", ContentMd: "a", Status: model.StatusPublished}); err != nil { t.Fatal(err) } token := login(t, h, "admin", "s3cret") var out struct { Token string `json:"token"` } _ = json.NewDecoder(token.Body).Decode(&out) req := httptest.NewRequest(http.MethodGet, "/api/admin/posts?order="+ strings.ReplaceAll("published_at DESC; DROP TABLE posts; --", " ", "%20"), nil) req.Header.Set("Authorization", "Bearer "+out.Token) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("injected order: got %d body=%s", rec.Code, rec.Body.String()) } // 表若被 DROP,这条查询会报错 if _, err := a.Store.List(store.ListOptions{Status: "any"}); err != nil { t.Fatalf("posts table damaged: %v", err) } }