package admin import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "oneblog/internal/model" ) // doAs 带着有效后台会话(owner)发一个请求。会话 token 只带用户 ID, // 所以先确保 owner 行存在,再按真实 ID 签。 func doAs(t *testing.T, a *API, method, path string, body string) *httptest.ResponseRecorder { t.Helper() owner, err := a.Store.EnsureOwner("admin") if err != nil { t.Fatal(err) } req := httptest.NewRequest(method, path, strings.NewReader(body)) if body != "" { req.Header.Set("Content-Type", "application/json") } sess := NewSessions("test-secret", time.Hour) tok, _ := sess.Issue(owner.ID) req.AddCookie(&http.Cookie{Name: cookieName, Value: tok}) rec := httptest.NewRecorder() a.Routes().ServeHTTP(rec, req) return rec } func TestAccountGET(t *testing.T) { a, _ := newTestAPI(t) if _, err := a.Store.EnsureOwner("admin"); err != nil { t.Fatal(err) } rec := doAs(t, a, http.MethodGet, "/api/admin/account", "") if rec.Code != http.StatusOK { t.Fatalf("got %d %s", rec.Code, rec.Body.String()) } var v struct { Name string `json:"name"` Handle string `json:"handle"` Password struct{} `json:"password"` Providers []string `json:"providers"` } if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil { t.Fatal(err) } if v.Handle != "admin" { t.Fatalf("handle=%q", v.Handle) } // 测试配置里没有 OAuth 凭据,可绑平台应为空 if len(v.Providers) != 0 { t.Fatalf("providers=%v, want empty", v.Providers) } } func TestAccountPATCHProfile(t *testing.T) { a, _ := newTestAPI(t) if _, err := a.Store.EnsureOwner("admin"); err != nil { t.Fatal(err) } rec := doAs(t, a, http.MethodPatch, "/api/admin/account", `{"name":"麻衣","bio":"活着就是为了樱岛麻衣"}`) if rec.Code != http.StatusOK { t.Fatalf("got %d %s", rec.Code, rec.Body.String()) } // 昵称要同时落在站主行与 settings(前端各处仍读 settings.author_name) owner, err := a.Store.GetOwner() if err != nil { t.Fatal(err) } if owner.Name != "麻衣" { t.Fatalf("owner.name=%q", owner.Name) } st, err := a.Store.GetSettings() if err != nil { t.Fatal(err) } if st.AuthorName != "麻衣" || st.AuthorBio != "活着就是为了樱岛麻衣" { t.Fatalf("settings 未同步: %+v", st) } } func TestAccountPATCHAvatarKey(t *testing.T) { a, _ := newTestAPI(t) if _, err := a.Store.EnsureOwner("admin"); err != nil { t.Fatal(err) } // 不存在的 key 必须拒:否则会存下一个永远解析不出的头像 rec := doAs(t, a, http.MethodPatch, "/api/admin/account", `{"avatar_key":"2026/09/nope.png"}`) if rec.Code != http.StatusBadRequest { t.Fatalf("不存在的 key: got %d, want 400", rec.Code) } // 真实存在但不是图片的也要拒 f, err := a.Store.CreateFile(model.File{ Key: "2026/09/notes.txt", Name: "notes.txt", Mime: "text/plain", Size: 4, SHA256: strings.Repeat("a", 64), Store: "local", }) if err != nil { t.Fatal(err) } rec = doAs(t, a, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+f.Key+`"}`) if rec.Code != http.StatusBadRequest { t.Fatalf("非图片: got %d, want 400", rec.Code) } // 图片就放行,并且单独写 owner_avatar_key(绕开 UpdateSettings 全量替换) img, err := a.Store.CreateFile(model.File{ Key: "2026/09/me.png", Name: "me.png", Mime: "image/png", Size: 4, SHA256: strings.Repeat("b", 64), Store: "local", }) if err != nil { t.Fatal(err) } rec = doAs(t, a, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+img.Key+`"}`) if rec.Code != http.StatusOK { t.Fatalf("图片头像: got %d %s", rec.Code, rec.Body.String()) } st, _ := a.Store.GetSettings() if st.AuthorAvatarKey != img.Key { t.Fatalf("avatar key=%q", st.AuthorAvatarKey) } if !strings.Contains(rec.Body.String(), "/uploads/"+img.Key) { t.Fatalf("响应里没解析出头像 URL: %s", rec.Body.String()) } // 站点设置的整体 PUT 不该把头像键冲掉(两者写入路径分开) if err := a.Store.UpdateSettings(st); err != nil { t.Fatal(err) } after, _ := a.Store.GetSettings() if after.AuthorAvatarKey != img.Key { t.Fatalf("UpdateSettings 把头像键清了: %q", after.AuthorAvatarKey) } } func TestAccountRejectsAnonymous(t *testing.T) { _, h := newTestAPI(t) for _, path := range []string{"/api/admin/account", "/api/admin/account/passkeys"} { 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 TestAccountUnbindUnknown(t *testing.T) { a, _ := newTestAPI(t) if _, err := a.Store.EnsureOwner("admin"); err != nil { t.Fatal(err) } rec := doAs(t, a, http.MethodDelete, "/api/admin/account/identities/github", "") if rec.Code != http.StatusNotFound { t.Fatalf("没绑过还解绑: got %d, want 404", rec.Code) } } // passkey 未配置时必须明确不可用,而不是假装成功 func TestPasskeysUnavailableWhenNil(t *testing.T) { a, _ := newTestAPI(t) if a.Passkeys != nil { t.Skip("测试构造里不该有 Passkeys") } rec := doAs(t, a, http.MethodPost, "/api/admin/account/passkeys/begin", "") if rec.Code != http.StatusServiceUnavailable { t.Fatalf("got %d, want 503", rec.Code) } }