package admin import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "golang.org/x/crypto/bcrypt" "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/projects", "/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) } } // 设置里的 ui_id / custom_css 必须能经 PUT→GET 往返,且非法分区被挡掉。 func TestSettingsUIRoundTrip(t *testing.T) { _, h := newTestAPI(t) token := login(t, h, "admin", "s3cret") var sess struct { Token string `json:"token"` } _ = json.NewDecoder(token.Body).Decode(&sess) if sess.Token == "" { t.Fatal("login failed") } put := func(body string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer "+sess.Token) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } rec := put(`{"site_title":"ONE","posts_per_page":10,"light_skin_id":"paper", "ui_id":"vivid","custom_css":{"home":"body{--v-accent:#f0f}","bogus":"x{}"}}`) if rec.Code != http.StatusOK { t.Fatalf("put settings: got %d body=%s", rec.Code, rec.Body.String()) } get := httptest.NewRequest(http.MethodGet, "/api/admin/settings", nil) get.Header.Set("Authorization", "Bearer "+sess.Token) rec = httptest.NewRecorder() h.ServeHTTP(rec, get) if rec.Code != http.StatusOK { t.Fatalf("get settings: got %d", rec.Code) } var wrap struct { Settings model.Settings `json:"settings"` CredentialMeta map[string]any `json:"credential_meta"` Secrets map[string]string `json:"-"` } if err := json.NewDecoder(rec.Body).Decode(&wrap); err != nil { t.Fatalf("decode: %v", err) } got := wrap.Settings if got.UIID != "vivid" { t.Errorf("ui_id = %q, want vivid", got.UIID) } if got.CustomCSS["home"] != "body{--v-accent:#f0f}" { t.Errorf("home css lost: %+v", got.CustomCSS) } if _, ok := got.CustomCSS["bogus"]; ok { t.Error("unknown section should not be persisted") } // 秘密项在任何响应里都不该出现明文 if strings.Contains(rec.Body.String(), "client_secret\":") && !strings.Contains(rec.Body.String(), "credential_meta") { t.Error("settings response should not carry raw secrets") } // An invalid ui_id falls back rather than being stored verbatim. rec = put(`{"site_title":"ONE","posts_per_page":10,"light_skin_id":"paper","ui_id":"neon","custom_css":{}}`) if rec.Code != http.StatusOK { t.Fatalf("put invalid ui: got %d body=%s", rec.Code, rec.Body.String()) } rec = httptest.NewRecorder() h.ServeHTTP(rec, get) _ = json.NewDecoder(rec.Body).Decode(&wrap) got = wrap.Settings if got.UIID != "classic" { t.Errorf("invalid ui_id should fall back to classic, got %q", got.UIID) } if got.CustomCSS == nil { t.Error("custom_css should be non-nil") } } // 系统设置迁移项:secrets 走单独通道写入且不可读回;DB 值在有效配置里 // 覆盖环境变量;登录改用 DB 密码后 env 密码是否还作数取决于是否显式配置。 func TestSettingsCredentialsRoundTrip(t *testing.T) { a, h := newTestAPI(t) a.Cfg = &config.Config{SiteURL: "http://env.example", AdminUser: "admin", AdminPass: "s3cret", GitHubClientID: "env-id", GitHubClientSecret: "env-sec", GoogleClientID: "env-google"} a.Res = config.NewResolver(a.Cfg, a.Store) token := login(t, h, "admin", "s3cret") var sess struct { Token string `json:"token"` } _ = json.NewDecoder(token.Body).Decode(&sess) put := func(body string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer "+sess.Token) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // 写入 DB 值(含一个 secret);响应与 GET 都不能回显 secret 明文 rec := put(`{"site_url":"https://db.example","github_client_id":"db-id", "secrets":{"github_client_secret":"db-sec","admin_password":"newpass1"}}`) if rec.Code != http.StatusOK { t.Fatalf("put: got %d body=%s", rec.Code, rec.Body.String()) } if strings.Contains(rec.Body.String(), "db-sec") || strings.Contains(rec.Body.String(), "newpass1") { t.Fatal("secret echoed back in plaintext") } // 来源标记:site_url 来自 db,client_id 来自 db,secret 来自 db; // 未写的项回落 env if meta, err := a.credentialMeta(); err != nil { t.Fatal(err) } else { want := map[string]string{ "site_url": "db", "github_client_id": "db", "github_client_secret": "db", "google_client_id": "env", "admin_password_hash": "db", } for k, src := range want { m, _ := meta[k].(map[string]any) if m == nil || m["source"] != src { t.Errorf("meta[%s] = %+v, want source=%s", k, m, src) } } } // 有效配置:DB 值覆盖 env;没配的项回落 env c := a.cfg() if c.SiteURL != "https://db.example" || c.GitHubClientID != "db-id" { t.Errorf("overlay failed: site_url=%q client_id=%q", c.SiteURL, c.GitHubClientID) } if c.GitHubClientSecret != "db-sec" { t.Errorf("secret overlay failed: %q", c.GitHubClientSecret) } // DB 密码立即生效;显式设置的 env 密码仍作后路;错误的都不行 if _, ok := a.verifyAdmin("admin", "newpass1"); !ok { t.Error("db password should work right after save") } if _, ok := a.verifyAdmin("admin", "s3cret"); !ok { t.Error("explicit env password should stay as backstop") } if _, ok := a.verifyAdmin("admin", "wrong"); ok { t.Error("wrong password must fail") } // admin_password 6 位下限 if rec := put(`{"secrets":{"admin_password":"123"}}`); rec.Code != http.StatusBadRequest { t.Errorf("short password: got %d, want 400", rec.Code) } } // InsecureDev(env 未显式设密码)时 admin/admin 有效,但后台一旦改了密码 // admin/admin 必须立刻失效。 func TestInsecureDevDisabledByDBPassword(t *testing.T) { a, _ := newTestAPI(t) a.Cfg = &config.Config{AdminUser: "admin", AdminPass: "admin", InsecureDev: true} if _, ok := a.verifyAdmin("admin", "admin"); !ok { t.Fatal("insecure default should work before any password is set") } h := bcryptHash(t, "newpass1") if err := a.Store.SetSetting("admin_password_hash", h); err != nil { t.Fatal(err) } if _, ok := a.verifyAdmin("admin", "admin"); ok { t.Error("admin/admin must stop working once a DB password exists") } if _, ok := a.verifyAdmin("admin", "newpass1"); !ok { t.Error("db password should be accepted") } } func bcryptHash(t *testing.T, pw string) string { t.Helper() b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost) if err != nil { t.Fatal(err) } return string(b) }