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 明文。 // 密码不再走 settings(多用户后在 users 行上),改走 account/password 端点。 rec := put(`{"site_url":"https://db.example","github_client_id":"db-id", "secrets":{"github_client_secret":"db-sec"}}`) 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") { t.Fatal("secret echoed back in plaintext") } // admin_password 已退役:出现即 400 if rec := put(`{"secrets":{"admin_password":"newpass1"}}`); rec.Code != http.StatusBadRequest { t.Errorf("legacy admin_password: got %d, want 400", rec.Code) } // 来源标记:site_url 来自 db,client_id 来自 db,secret 来自 db; // 未写的项回落 env;密码此时还在 env 引导链上(首次登录已自迁移成行哈希, // 来源也是 db) 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) } // 自己改密码:旧密码错被拒,对了立即生效;显式 env 密码仍作后路 patch := func(body string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPatch, "/api/admin/account/password", 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 } if rec := patch(`{"old_password":"wrong","new_password":"newpass1"}`); rec.Code != http.StatusForbidden { t.Errorf("wrong old password: got %d, want 403", rec.Code) } if rec := patch(`{"old_password":"s3cret","new_password":"newpass1"}`); rec.Code != http.StatusOK { t.Errorf("change password: got %d body=%s", rec.Code, rec.Body.String()) } if _, ok := a.verifyAdmin("admin", "newpass1"); !ok { t.Error("row password should work right after change") } 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") } } // InsecureDev(env 未显式设密码)时 admin/admin 有效,但后台一旦改了密码 // admin/admin 必须立刻失效。 // InsecureDev(env 未显式设密码)时 admin/admin 有效;首次登录会把引导凭据 // 自迁移成 owner 行哈希——之后站主改了密码(行哈希更新),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") } owner, err := a.Store.EnsureOwner("admin") if err != nil { t.Fatal(err) } if owner.PasswordHash == "" { t.Fatal("first login should migrate bootstrap credentials onto the owner row") } if err := a.Store.SetStaffPassword(owner.ID, bcryptHash(t, "newpass1")); err != nil { t.Fatal(err) } if _, ok := a.verifyAdmin("admin", "admin"); ok { t.Error("admin/admin must stop working once a row password exists") } if _, ok := a.verifyAdmin("admin", "newpass1"); !ok { t.Error("row 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) } // 多用户与角色:owner 创建 admin 账号;admin 能进内容接口, // 动不了系统设置与用户管理;被停用后存量会话立即失效。 func TestMultiUserLifecycle(t *testing.T) { _, h := newTestAPI(t) tokOf := func(user, pass string) string { rec := login(t, h, user, pass) var out struct { Token string `json:"token"` Role string `json:"role"` } if rec.Code != http.StatusOK { t.Fatalf("login %s: got %d body=%s", user, rec.Code, rec.Body.String()) } if err := json.NewDecoder(rec.Body).Decode(&out); err != nil || out.Token == "" { t.Fatalf("login %s: no token: %v", user, err) } if user == "admin" && out.Role != model.RoleOwner { t.Errorf("owner role = %q", out.Role) } return out.Token } reqAs := func(tok, method, path, body string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, path, strings.NewReader(body)) req.Header.Set("Authorization", "Bearer "+tok) if body != "" { req.Header.Set("Content-Type", "application/json") } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } owner := tokOf("admin", "s3cret") // owner 创建内容管理员 rec := reqAs(owner, http.MethodPost, "/api/admin/users", `{"username":"editor","password":"editor1"}`) if rec.Code != http.StatusCreated { t.Fatalf("create staff: got %d body=%s", rec.Code, rec.Body.String()) } // 用户名占用 if rec := reqAs(owner, http.MethodPost, "/api/admin/users", `{"username":"editor","password":"editor1"}`); rec.Code != http.StatusConflict { t.Errorf("duplicate username: got %d, want 409", rec.Code) } // staff 登录,角色是 admin staff := tokOf("editor", "editor1") // 内容接口可进 if rec := reqAs(staff, http.MethodGet, "/api/admin/posts", ""); rec.Code != http.StatusOK { t.Errorf("staff read posts: got %d", rec.Code) } // 系统设置写不了、用户管理进不去 if rec := reqAs(staff, http.MethodPut, "/api/admin/settings", `{"site_title":"x"}`); rec.Code != http.StatusForbidden { t.Errorf("staff put settings: got %d, want 403", rec.Code) } if rec := reqAs(staff, http.MethodGet, "/api/admin/users", ""); rec.Code != http.StatusForbidden { t.Errorf("staff list users: got %d, want 403", rec.Code) } if rec := reqAs(staff, http.MethodPost, "/api/admin/users", `{"username":"x2","password":"xxxxxx"}`); rec.Code != http.StatusForbidden { t.Errorf("staff create user: got %d, want 403", rec.Code) } // owner 重置 staff 密码后,新密码立即生效 staffID := 0 list := reqAs(owner, http.MethodGet, "/api/admin/users", "") var lu struct { Users []model.Reader `json:"users"` } _ = json.NewDecoder(list.Body).Decode(&lu) for _, u := range lu.Users { if u.Handle == "editor" { staffID = int(u.ID) if u.Role != model.RoleAdmin { t.Errorf("staff role = %q, want admin", u.Role) } } } if staffID == 0 { t.Fatal("editor not in staff list") } if rec := reqAs(owner, http.MethodPatch, "/api/admin/users/"+itoa(int64(staffID)), `{"password":"reset99"}`); rec.Code != http.StatusOK { t.Errorf("reset password: got %d", rec.Code) } if rec := login(t, h, "editor", "reset99"); rec.Code != http.StatusOK { t.Errorf("login with reset password: got %d", rec.Code) } // 停用后存量会话立即 401;owner 行与自身不可停用 / 不可删 if rec := reqAs(owner, http.MethodPatch, "/api/admin/users/"+itoa(int64(staffID)), `{"banned":true}`); rec.Code != http.StatusOK { t.Fatalf("ban staff: got %d", rec.Code) } if rec := reqAs(staff, http.MethodGet, "/api/admin/posts", ""); rec.Code != http.StatusUnauthorized { t.Errorf("banned staff session: got %d, want 401", rec.Code) } if rec := login(t, h, "editor", "reset99"); rec.Code != http.StatusUnauthorized { t.Errorf("banned staff login: got %d, want 401", rec.Code) } if rec := reqAs(owner, http.MethodDelete, "/api/admin/users/"+itoa(int64(staffID)), ""); rec.Code != http.StatusOK { t.Errorf("delete staff: got %d", rec.Code) } // owner 行自我保护 if rec := reqAs(owner, http.MethodDelete, "/api/admin/users/1", ""); rec.Code == http.StatusOK { t.Error("owner row must not be deletable") } }