Files
ONE/backend/internal/api/account_test.go
T
Sakurasan e68400b389 系统设置迁入后台:站点地址 / OAuth 凭据 / R2 存储 / 管理员账号,保存即生效无需重启
- 生效规则统一为「后台填了用后台的,没填回落环境变量」,老部署不改 env 照常跑
- config.Resolver:短缓存解析有效配置,存储后端按配置签名热重建;后台保存主动失效
- 秘密项(client secret / bot token / R2 密钥 / 管理员密码)接口永不回显明文,
  只报「是否已配置、来自哪里」;留空保存 = 保持现值
- 管理员密码 bcrypt 入库,DB 哈希优先、显式设置的 env 密码作解锁后路;
  后台改过密码后 admin/admin 开发模式立即失效
- 设置页新增「登录与存储」标签,基础信息加站点地址;秘密项带来源提示
- 监听地址 / 数据库 / 目录 / ONE_SECRET / Passkey 仍留环境变量(启动期依赖)
2026-10-01 12:14:10 +08:00

188 lines
5.8 KiB
Go

package api
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"oneblog/internal/auth"
"oneblog/internal/config"
"oneblog/internal/model"
)
// fakeAdmin 满足 API.AdminSessions 接口。不 import admin 包 ——
// 两个 API 之间不该为了测试互相依赖。
type fakeAdmin struct{ valid map[string]bool }
func (f fakeAdmin) Verify(token string) (string, error) {
if f.valid[token] {
return "admin", nil
}
return "", errors.New("bad session")
}
func (f fakeAdmin) Issue(string) (string, time.Time) {
return "issued-admin-token", time.Now().Add(time.Hour)
}
func (f fakeAdmin) TTL() int { return 3600 }
func newAccountAPI(t *testing.T) (*API, http.Handler) {
t.Helper()
a, h := newTestAPI(t)
a.Cfg = &config.Config{SiteURL: "http://localhost:8080", AdminUser: "admin",
GitHubClientID: "id", GitHubClientSecret: "sec"}
a.AdminSessions = fakeAdmin{valid: map[string]bool{"good-session": true}}
return a, h
}
// 绑定动作必须已登录后台 —— 否则任何人都能往别人账号上塞身份
func TestBindRequiresAdminSession(t *testing.T) {
_, h := newAccountAPI(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/github/bind", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("无会话绑定: got %d, want 401", rec.Code)
}
}
func TestBindWithAdminSessionLinksAndRedirects(t *testing.T) {
a, h := newAccountAPI(t)
owner, err := a.Store.EnsureOwner("admin")
if err != nil {
t.Fatal(err)
}
back := a.siteURL() + "/admin/account"
req := httptest.NewRequest(http.MethodGet, "/api/auth/github/bind", nil)
req.AddCookie(&http.Cookie{Name: "one_session", Value: "good-session"})
req.AddCookie(&http.Cookie{Name: oauthBackCook, Value: back})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("got %d %s, want 302 跳 GitHub", rec.Code, rec.Body.String())
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "https://github.com/login/oauth/authorize") {
t.Fatalf("没跳授权页: %s", loc)
}
// 绑定意图必须落到一次性 cookie 上
var sawBind bool
for _, ck := range rec.Result().Cookies() {
if ck.Name == oauthBindCook && ck.Value == "1" {
sawBind = true
}
}
if !sawBind {
t.Fatal("绑定 cookie 没设上,回调时无从判断意图")
}
_ = owner
}
// 回调命中「已绑定给站主」的身份 → 必须发后台会话,而不是读者会话
func TestBoundOwnerIdentityGrantsAdminSession(t *testing.T) {
a, _ := newAccountAPI(t)
owner, err := a.Store.EnsureOwner("admin")
if err != nil {
t.Fatal(err)
}
if err := a.Store.BindIdentity(owner.ID, "github", "4242", "littleckin"); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/callback/github", strings.NewReader("code=x&state=y"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
// 直接测分流函数:绕开真 GitHub
back := a.afterIdentity(rec, req, "github", "4242", "littleckin",
model.Reader{Provider: "github", Handle: "littleckin", Name: "n"})
if back != "" {
t.Fatalf("站主命中绑定应自己收尾(不发跳),got back=%q", back)
}
var admin, reader bool
for _, ck := range rec.Result().Cookies() {
switch ck.Name {
case "one_session":
admin = ck.Value == "issued-admin-token"
case auth.ReaderCookie:
reader = true
}
}
if !admin {
t.Fatal("没发后台会话")
}
if reader {
t.Fatal("站主登录不该只拿到读者会话")
}
}
// 陌生身份(没绑定)走老路:建读者档案 + 发读者会话 + 回跳
func TestUnknownIdentityFallsBackToReader(t *testing.T) {
a, _ := newAccountAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.AddCookie(&http.Cookie{Name: oauthBackCook, Value: "http://localhost:3000"})
rec := httptest.NewRecorder()
back := a.afterIdentity(rec, req, "github", "999", "stranger",
model.Reader{Provider: "github", Handle: "stranger", Name: "Stranger"})
if back != "http://localhost:3000" {
t.Fatalf("回跳地址不对: %q", back)
}
var reader bool
for _, ck := range rec.Result().Cookies() {
if ck.Name == auth.ReaderCookie && ck.Value != "" {
reader = true
}
if ck.Name == "one_session" {
t.Fatal("陌生身份拿到了后台会话")
}
}
if !reader {
t.Fatal("没发读者会话")
}
}
// 已被别人绑走的外部账号,不能再绑给站主
func TestBindConflictReturns409(t *testing.T) {
a, _ := newAccountAPI(t)
owner, _ := a.Store.EnsureOwner("admin")
other, err := a.Store.UpsertReader(model.Reader{Provider: "github", Handle: "real-owner", Name: "x"})
if err != nil {
t.Fatal(err)
}
if err := a.Store.BindIdentity(other.ID, "github", "4242", "real-owner"); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.AddCookie(&http.Cookie{Name: oauthBindCook, Value: "1"})
rec := httptest.NewRecorder()
a.afterIdentity(rec, req, "github", "4242", "hijack",
model.Reader{Provider: "github", Handle: "hijack", Name: "h"})
if rec.Code != http.StatusConflict {
t.Fatalf("got %d, want 409", rec.Code)
}
// 确认没被抢走
got, err := a.Store.GetUserByIdentity("github", "4242")
if err != nil || got.ID != other.ID {
t.Fatalf("身份归属被改动: %+v err=%v", got, err)
}
_ = owner
}
// passkey 未启用时公开端点要 404,不能 500
func TestPasskeyEndpointsAbsentWhenDisabled(t *testing.T) {
a, h := newAccountAPI(t)
if a.Passkeys != nil {
t.Skip("Passkeys 应未装配")
}
for _, p := range []string{"/api/auth/passkey/begin", "/api/auth/passkey/finish"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, p, strings.NewReader("{}")))
if rec.Code != http.StatusNotFound {
t.Errorf("%s: got %d, want 404", p, rec.Code)
}
}
}