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) (int64, error) { if f.valid[token] { return 1, nil } return 0, errors.New("bad session") } func (f fakeAdmin) Issue(int64) (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) } } }